mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
11 Commits
f355b99bbf
...
be34eecb2f
| Author | SHA1 | Date | |
|---|---|---|---|
| be34eecb2f | |||
| 5d9f95f8d4 | |||
| 48b70a03d5 | |||
| 4b8642b972 | |||
| 8711d51eac | |||
| 58dfb6da64 | |||
| d7735d1faa | |||
| 6b59166f3c | |||
| 3a26028d17 | |||
| 3c91bf4ad2 | |||
| 6a434be0f6 |
@ -288,6 +288,7 @@ trait Testable
|
||||
if ($cached !== null) {
|
||||
if ($cached->isSuccess()) {
|
||||
$this->__cachedPass = true;
|
||||
$this->__ran = true;
|
||||
|
||||
return;
|
||||
}
|
||||
@ -299,6 +300,7 @@ trait Testable
|
||||
// programmatic risky-marker API.
|
||||
if ($cached->isRisky()) {
|
||||
$this->__cachedPass = true;
|
||||
$this->__ran = true;
|
||||
|
||||
return;
|
||||
}
|
||||
@ -313,6 +315,7 @@ trait Testable
|
||||
|
||||
if ($cached->isIncomplete()) {
|
||||
$this->markTestIncomplete($cached->message());
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
|
||||
|
||||
38
src/Exceptions/NoAffectedTestsFound.php
Normal file
38
src/Exceptions/NoAffectedTestsFound.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
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([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exit code to be used.
|
||||
*/
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -11,64 +11,39 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Pulls a team-shared TIA baseline on the first `--tia` run so new
|
||||
* contributors and fresh CI workspaces start in replay mode instead of
|
||||
* paying the ~30s record cost.
|
||||
* 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.
|
||||
*
|
||||
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
|
||||
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
|
||||
* suite under `--tia` and uploads the `.pest/tia/` directory as a named
|
||||
* artifact (`pest-tia-baseline`) containing `graph.json` +
|
||||
* `coverage.bin`. On dev
|
||||
* machines, this class finds the latest successful run of that workflow
|
||||
* and downloads the artifact via `gh`.
|
||||
*
|
||||
* Why artifacts, not releases:
|
||||
* - No tag is created → no `push` event cascade into CI workflows.
|
||||
* - No release event → no deploy workflows tied to `release:published`.
|
||||
* - Retention is run-scoped and tunable (1-90 days) instead of clobbering
|
||||
* a single floating tag.
|
||||
* - Publishing is strictly CI-only: artifacts can't be produced from a
|
||||
* developer's laptop. This enforces the "CI is the authoritative
|
||||
* publisher" policy that local-publish paths would otherwise erode.
|
||||
*
|
||||
* Fingerprint validation happens back in `Tia::handleParent` after the
|
||||
* blobs are written: a mismatched environment (different PHP version,
|
||||
* composer.lock, etc.) discards the pulled baseline and falls through to
|
||||
* the regular record path.
|
||||
* 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
|
||||
{
|
||||
/**
|
||||
* Conventional workflow filename teams publish from. Not configurable
|
||||
* for MVP — teams that outgrow the default can set
|
||||
* `PEST_TIA_BASELINE_WORKFLOW` later.
|
||||
*/
|
||||
private const string WORKFLOW_FILE = 'tia-baseline.yml';
|
||||
|
||||
/**
|
||||
* Artifact name the workflow uploads under. The artifact is a zip
|
||||
* containing `graph.json` (always) + `coverage.bin` (optional).
|
||||
*/
|
||||
private const string ARTIFACT_NAME = 'pest-tia-baseline';
|
||||
|
||||
/**
|
||||
* Asset filenames inside the artifact — mirror the state keys so the
|
||||
* CI publisher and the sync consumer stay in lock-step.
|
||||
*/
|
||||
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
||||
|
||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||
|
||||
/**
|
||||
* Cooldown (in seconds) applied after a failed baseline fetch.
|
||||
* Rationale: when the remote workflow hasn't published yet, every
|
||||
* `pest --tia` invocation would otherwise re-hit `gh run list` and
|
||||
* re-print the publish instructions — noisy + slow. Back off for a
|
||||
* day, let the user override with `--refetch`.
|
||||
*/
|
||||
// Project-local directory 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_REL_DIR = '.pest/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(
|
||||
@ -76,16 +51,6 @@ final readonly class BaselineSync
|
||||
private OutputInterface $output,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Detects the repo, fetches the latest baseline artifact, writes its
|
||||
* contents into the TIA state store. Returns true when the graph blob
|
||||
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
|
||||
* never reads it.
|
||||
*
|
||||
* `$force = true` (driven by `--refetch`) ignores the post-failure
|
||||
* cooldown so the user can retry on demand without waiting out the
|
||||
* 24h window.
|
||||
*/
|
||||
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
|
||||
{
|
||||
$repo = $this->detectGitHubRepo($projectRoot);
|
||||
@ -109,7 +74,7 @@ final readonly class BaselineSync
|
||||
$repo,
|
||||
));
|
||||
|
||||
$payload = $this->download($repo);
|
||||
$payload = $this->download($repo, $projectRoot);
|
||||
|
||||
if ($payload === null) {
|
||||
$this->startCooldown();
|
||||
@ -126,9 +91,6 @@ final readonly class BaselineSync
|
||||
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
||||
}
|
||||
|
||||
// Successful fetch wipes any stale cooldown so the next failure
|
||||
// (say, weeks later) starts a fresh 24h timer rather than inheriting
|
||||
// one from the deep past.
|
||||
$this->clearCooldown();
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
@ -139,10 +101,6 @@ final readonly class BaselineSync
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds left on the cooldown, or `null` when the cooldown is cleared
|
||||
* / expired / unreadable.
|
||||
*/
|
||||
private function cooldownRemaining(): ?int
|
||||
{
|
||||
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
|
||||
@ -187,18 +145,6 @@ final readonly class BaselineSync
|
||||
return $seconds.'s';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints actionable instructions for publishing a first baseline when
|
||||
* the consumer-side fetch finds nothing.
|
||||
*
|
||||
* Behaviour splits on environment:
|
||||
* - **CI:** a single line. The current run is almost certainly *the*
|
||||
* publisher (it's what this workflow does by definition), so
|
||||
* printing the whole recipe again is redundant and noisy.
|
||||
* - **Local:** the full recipe, adapted to Laravel's pre-test steps
|
||||
* (`.env.example` copy + `artisan key:generate`) when the framework
|
||||
* is present. Generic PHP projects get a slimmer skeleton.
|
||||
*/
|
||||
private function emitPublishInstructions(string $repo): void
|
||||
{
|
||||
if ($this->isCi()) {
|
||||
@ -237,12 +183,7 @@ final readonly class BaselineSync
|
||||
$this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when running inside a CI provider. Conservative list — only the
|
||||
* three providers Pest formally supports / sees in the wild. `CI=true`
|
||||
* alone is ambiguous (users set it locally too) so we require a
|
||||
* provider-specific flag.
|
||||
*/
|
||||
// `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var.
|
||||
private function isCi(): bool
|
||||
{
|
||||
return getenv('GITHUB_ACTIONS') === 'true'
|
||||
@ -256,12 +197,6 @@ final readonly class BaselineSync
|
||||
&& InstalledVersions::isInstalled('laravel/framework');
|
||||
}
|
||||
|
||||
/**
|
||||
* Laravel projects need a populated `.env` and a generated `APP_KEY`
|
||||
* before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException`
|
||||
* fires during `setUp`. Include the standard pre-test dance plus the
|
||||
* extension set typical Laravel apps rely on.
|
||||
*/
|
||||
private function laravelWorkflowYaml(): string
|
||||
{
|
||||
return <<<'YAML'
|
||||
@ -329,12 +264,6 @@ jobs:
|
||||
YAML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `.git/config` for the `origin` remote and extracts
|
||||
* `org/repo`. Supports the two URL flavours git emits out of the box.
|
||||
* Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which
|
||||
* silently opts the repo out of auto-sync.
|
||||
*/
|
||||
private function detectGitHubRepo(string $projectRoot): ?string
|
||||
{
|
||||
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||
@ -349,29 +278,20 @@ YAML;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the `[remote "origin"]` section and the first `url` line
|
||||
// inside it. Tolerates INI whitespace quirks (tabs, CRLF).
|
||||
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $match[1];
|
||||
|
||||
// SSH: git@github.com:org/repo(.git)
|
||||
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
// HTTPS: https://github.com/org/repo(.git) (optional trailing slash)
|
||||
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
// SSH URL form: ssh://[user@]github.com[:port]/org/repo(.git).
|
||||
// Some teams configure this explicitly to pin the SSH port; the
|
||||
// colon-separated form above doesn't match. Mirrors the parser
|
||||
// in `Storage::originIdentity` so the same remote produces the
|
||||
// same project key for both storage and remote-fetch.
|
||||
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
@ -379,16 +299,8 @@ YAML;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-step fetch: find the latest successful run of the baseline
|
||||
* workflow, then download the named artifact from it. Returns
|
||||
* `['graph' => bytes, 'coverage' => bytes|null]` on success, or null
|
||||
* if `gh` is unavailable, the workflow hasn't run yet, the artifact
|
||||
* is missing, or any shell step fails.
|
||||
*
|
||||
* @return array{graph: string, coverage: ?string}|null
|
||||
*/
|
||||
private function download(string $repo): ?array
|
||||
/** @return array{graph: string, coverage: ?string}|null */
|
||||
private function download(string $repo, string $projectRoot): ?array
|
||||
{
|
||||
if (! $this->commandExists('gh')) {
|
||||
return null;
|
||||
@ -400,9 +312,20 @@ YAML;
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
|
||||
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
||||
|
||||
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
|
||||
// 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);
|
||||
|
||||
return $this->readArtifact($runCacheDir);
|
||||
}
|
||||
|
||||
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -410,43 +333,123 @@ YAML;
|
||||
'gh', 'run', 'download', $runId,
|
||||
'-R', $repo,
|
||||
'-n', self::ARTIFACT_NAME,
|
||||
'-D', $tmpDir,
|
||||
'-D', $runCacheDir,
|
||||
]);
|
||||
$process->setTimeout(120.0);
|
||||
$process->setTimeout(900.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
$this->cleanup($tmpDir);
|
||||
$this->cleanup($runCacheDir);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||
$payload = $this->readArtifact($runCacheDir);
|
||||
|
||||
if ($payload === null) {
|
||||
$this->cleanup($runCacheDir);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->trimDownloadCache($projectRoot);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{graph: string, coverage: ?string}|null
|
||||
*/
|
||||
private function readArtifact(string $dir): ?array
|
||||
{
|
||||
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||
|
||||
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
|
||||
|
||||
if ($graph === false) {
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
|
||||
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return [
|
||||
'graph' => $graph,
|
||||
'coverage' => $coverage === false ? null : $coverage,
|
||||
];
|
||||
}
|
||||
|
||||
private function downloadCacheDir(string $projectRoot): string
|
||||
{
|
||||
return rtrim($projectRoot, '/\\')
|
||||
.DIRECTORY_SEPARATOR
|
||||
.str_replace('/', DIRECTORY_SEPARATOR, self::DOWNLOAD_CACHE_REL_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries GitHub for the most recent successful run of the baseline
|
||||
* workflow. `--jq '.[0].databaseId // empty'` coerces "no runs found"
|
||||
* into an empty string, which we map to null.
|
||||
* 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) ?? '';
|
||||
|
||||
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);
|
||||
|
||||
if (! is_dir($root)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = @scandir($root);
|
||||
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (! is_dir($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mtime = @filemtime($path);
|
||||
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
|
||||
}
|
||||
|
||||
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
usort(
|
||||
$candidates,
|
||||
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
|
||||
);
|
||||
|
||||
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
|
||||
$this->cleanup($stale['path']);
|
||||
}
|
||||
}
|
||||
|
||||
private function latestSuccessfulRunId(string $repo): ?string
|
||||
{
|
||||
$process = new Process([
|
||||
|
||||
@ -27,17 +27,6 @@ final readonly class ChangedFiles
|
||||
public function __construct(private string $projectRoot) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||
* the recorded SHA is no longer reachable
|
||||
* from HEAD (rebase / force-push) — in
|
||||
* that case the graph should be rebuilt.
|
||||
*/
|
||||
/**
|
||||
* Removes files whose current content hash matches the snapshot from the
|
||||
* last `--tia` run. Used to ignore "dirty but unchanged" files — a file
|
||||
* that git still reports as modified but whose content is bit-identical
|
||||
* to the previous TIA invocation.
|
||||
*
|
||||
* @param array<int, string> $files project-relative paths.
|
||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||
* @return array<int, string>
|
||||
@ -48,12 +37,7 @@ final readonly class ChangedFiles
|
||||
return $files;
|
||||
}
|
||||
|
||||
// Union: `$files` (what git currently reports) + every path that was
|
||||
// dirty last run. The second set matters for reverts — when a user
|
||||
// undoes a local edit, the file matches HEAD again and git reports
|
||||
// it clean, so it would never enter `$files`. But it has genuinely
|
||||
// changed vs the snapshot we captured during the bad run, so it
|
||||
// must be checked.
|
||||
// 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) {
|
||||
@ -68,28 +52,14 @@ final readonly class ChangedFiles
|
||||
$exists = is_file($absolute);
|
||||
|
||||
if ($snapshot === null) {
|
||||
// File wasn't in last-run tree at all — trust git's signal.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $exists) {
|
||||
// Missing on disk. We always invalidate here, even when
|
||||
// the snapshot also recorded "deleted" (sentinel '').
|
||||
// The `snapshot=='' && !exists` shortcut would in
|
||||
// principle say "no change since last run, cached
|
||||
// result is still valid" — but it's only safe if the
|
||||
// cached result was recorded *during* a run that saw
|
||||
// the file as deleted. A previous run that captured
|
||||
// the deletion in `lastRunTree` but failed to refresh
|
||||
// the cached pass/fail (paratest worker race, an
|
||||
// earlier plugin bug, etc.) would leave the cache
|
||||
// stuck on a stale pass from before the deletion.
|
||||
// Skipping invalidation in that state perpetuates the
|
||||
// wrong result on every subsequent run. Treat any
|
||||
// missing file as a change; cost is one re-run per
|
||||
// `--tia` while the file stays deleted.
|
||||
// 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;
|
||||
@ -104,21 +74,9 @@ final readonly class ChangedFiles
|
||||
}
|
||||
|
||||
if ($hash === $snapshot) {
|
||||
// Same state as the last TIA invocation — cached
|
||||
// result is still valid, no need to re-run.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Differs from the snapshot. This includes the
|
||||
// revert-back-to-baseline case (last run had a real edit
|
||||
// and was cached against that edit; this run reverted).
|
||||
// Even though the file now matches what's at the recorded
|
||||
// SHA, the cached test result reflects the *modified*
|
||||
// version, not the baseline version — so it's stale and
|
||||
// the test must re-run to refresh the cache. An earlier
|
||||
// version of this filter short-circuited on
|
||||
// matches-baseline, which served the stale failure
|
||||
// forever after the user reverted.
|
||||
$remaining[] = $file;
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,53 @@ use Pest\Support\Container;
|
||||
*/
|
||||
final class Configuration
|
||||
{
|
||||
/**
|
||||
* Activates TIA for every run without requiring the `--tia` CLI flag.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function always(): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markEnabled();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markEnabled();
|
||||
$watchPatterns->markLocally();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markFiltered();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds watch-pattern → test-directory mappings that supplement (or
|
||||
* override) the built-in defaults.
|
||||
|
||||
@ -5,30 +5,14 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Captures environmental inputs that, when changed, may make the TIA graph
|
||||
* or its recorded results stale. The fingerprint is split into two buckets:
|
||||
* Two-bucket fingerprint for TIA staleness detection.
|
||||
*
|
||||
* - **structural** — describes what the graph's *edges* were recorded
|
||||
* against. If any of these drift (`composer.lock`, `composer.json`,
|
||||
* `phpunit.xml{,.dist}`, `vite.config.*`, Pest's factory codegen) the
|
||||
* edges themselves are potentially wrong and the graph must rebuild
|
||||
* from scratch. `tests/TestCase.php` and `tests/Pest.php` are
|
||||
* intentionally NOT here — those are handled by per-test ancestor
|
||||
* linking (`Recorder::linkAncestorFiles`) and the Php watch pattern
|
||||
* respectively, which give precise invalidation rather than a wholesale
|
||||
* rebuild.
|
||||
* - **environmental** — describes the *runtime* the results were captured
|
||||
* on (PHP minor, extension set). Drift here means the edges are still
|
||||
* trustworthy, but the cached per-test results (pass/fail/time) may
|
||||
* not reproduce on this machine. Tia's handler drops the branch's
|
||||
* results + coverage cache and re-runs to freshen them, rather than
|
||||
* re-recording from scratch. Pest's own version is intentionally NOT
|
||||
* here — `composer.lock`'s structural hash already moves whenever the
|
||||
* installed Pest version changes.
|
||||
*
|
||||
* Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
|
||||
* rebuilt on first load; the schema bump in the structural bucket takes
|
||||
* care of that automatically.
|
||||
* - **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
|
||||
*/
|
||||
@ -83,7 +67,11 @@ final readonly class Fingerprint
|
||||
// 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.
|
||||
private const int SCHEMA_VERSION = 13;
|
||||
// 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;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
@ -96,40 +84,19 @@ final readonly class Fingerprint
|
||||
return [
|
||||
'structural' => [
|
||||
'schema' => self::SCHEMA_VERSION,
|
||||
// `composer.lock` hashed against a *behavioural*
|
||||
// subset (per-package version + reference + autoload +
|
||||
// extra). Skips per-package install timestamps, dist
|
||||
// URLs, support links, descriptions — none of which
|
||||
// affect what code runs.
|
||||
'composer_lock' => self::composerLockHash($projectRoot),
|
||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||
// Pest's generated classes bake the code-generation logic
|
||||
// in — if TestCaseFactory changes (new attribute, different
|
||||
// method signature, etc.) every previously-recorded edge is
|
||||
// stale. Hashing via `ContentHash::of()` so cosmetic edits
|
||||
// (comments, formatting) don't drift the fingerprint.
|
||||
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||
// `vite.config.*` reshapes the module graph
|
||||
// `JsModuleGraph` records at the next `--tia` run; if
|
||||
// the config drifts without a rebuild, the stored
|
||||
// `$jsFileToComponents` map is silently stale.
|
||||
// `viteConfigHash` itself uses `ContentHash::of()` so
|
||||
// a comment-only edit to vite.config doesn't rebuild.
|
||||
'vite_config' => self::viteConfigHash($projectRoot),
|
||||
// `composer.json` hashed against a behavioural subset:
|
||||
// autoload(-dev), require(-dev), extra (Laravel
|
||||
// package discovery), repositories, minimum-stability,
|
||||
// and the platform / allow-plugins entries from
|
||||
// `config`. Cosmetic fields (description, keywords,
|
||||
// scripts, authors, funding, support) are excluded.
|
||||
'package_json' => self::packageJsonHash($projectRoot),
|
||||
'package_lock' => self::packageLockHash($projectRoot),
|
||||
'js_config' => self::jsConfigHash($projectRoot),
|
||||
'composer_json' => self::composerJsonHash($projectRoot),
|
||||
],
|
||||
'environmental' => [
|
||||
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
|
||||
// almost never matches a dev's Herd/Homebrew install, and
|
||||
// the patch rarely changes anything test-visible.
|
||||
// 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),
|
||||
@ -138,9 +105,6 @@ final readonly class Fingerprint
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the structural buckets match. Drift here means the edges
|
||||
* are potentially wrong; caller should discard the graph and rebuild.
|
||||
*
|
||||
* @param array<string, mixed> $a
|
||||
* @param array<string, mixed> $b
|
||||
*/
|
||||
@ -156,12 +120,6 @@ final readonly class Fingerprint
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of structural field names that drifted between
|
||||
* the stored and current fingerprints. Empty list = no drift.
|
||||
* Caller uses this to tell the user *why* the graph rebuilt — a
|
||||
* generic "graph outdated" message leaves people staring at
|
||||
* unrelated diffs.
|
||||
*
|
||||
* @param array<string, mixed> $stored
|
||||
* @param array<string, mixed> $current
|
||||
* @return list<string>
|
||||
@ -195,11 +153,6 @@ final readonly class Fingerprint
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of field names that drifted between the stored and
|
||||
* current environmental fingerprints. Empty list = no drift. Caller
|
||||
* uses this to print a human-readable warning and to decide whether
|
||||
* per-test results should be dropped (any drift → yes).
|
||||
*
|
||||
* @param array<string, mixed> $stored
|
||||
* @param array<string, mixed> $current
|
||||
* @return list<string>
|
||||
@ -244,12 +197,8 @@ final readonly class Fingerprint
|
||||
return self::bucket($fingerprint, 'environmental');
|
||||
}
|
||||
|
||||
// Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild.
|
||||
/**
|
||||
* Returns `$fingerprint[$key]` as an `array<string, mixed>` if it exists
|
||||
* and is an array, otherwise empty. Legacy flat-shape fingerprints
|
||||
* (schema ≤ 3) return empty here, which makes `structuralMatches` fail
|
||||
* and the caller rebuild — the clean migration path.
|
||||
*
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@ -272,13 +221,6 @@ final readonly class Fingerprint
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hash of every `vite.config.{ts,js,mjs,cjs,mts}` present
|
||||
* at the project root. Most projects have exactly one; we accept
|
||||
* any of the five recognised extensions without assuming which
|
||||
* the user picked. Returns null when no config file exists —
|
||||
* treated as "no Vite project" by the matcher, no drift.
|
||||
*/
|
||||
private static function viteConfigHash(string $projectRoot): ?string
|
||||
{
|
||||
$parts = [];
|
||||
@ -294,12 +236,79 @@ final readonly class Fingerprint
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes environment files that can globally alter app boot behaviour.
|
||||
* These files are often git-ignored, so they cannot rely on changed-file
|
||||
* detection. The environmental bucket keeps graph edges while forcing all
|
||||
* cached results to refresh after an env edit.
|
||||
*/
|
||||
private static function jsConfigHash(string $projectRoot): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
|
||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function packageJsonHash(string $projectRoot): ?string
|
||||
{
|
||||
$path = $projectRoot.'/package.json';
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
$hash = @hash_file('xxh128', $path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
$relevant = [
|
||||
'type' => $data['type'] ?? null,
|
||||
'packageManager' => $data['packageManager'] ?? null,
|
||||
'dependencies' => $data['dependencies'] ?? null,
|
||||
'devDependencies' => $data['devDependencies'] ?? null,
|
||||
'optionalDependencies' => $data['optionalDependencies'] ?? null,
|
||||
'peerDependencies' => $data['peerDependencies'] ?? null,
|
||||
'overrides' => $data['overrides'] ?? null,
|
||||
'resolutions' => $data['resolutions'] ?? null,
|
||||
'imports' => $data['imports'] ?? null,
|
||||
'exports' => $data['exports'] ?? null,
|
||||
'browser' => $data['browser'] ?? null,
|
||||
];
|
||||
|
||||
self::sortRecursively($relevant);
|
||||
|
||||
$json = json_encode($relevant);
|
||||
|
||||
return $json === false ? null : hash('xxh128', $json);
|
||||
}
|
||||
|
||||
private static function packageLockHash(string $projectRoot): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function envFilesHash(string $projectRoot): ?string
|
||||
{
|
||||
$paths = [
|
||||
@ -348,14 +357,6 @@ final readonly class Fingerprint
|
||||
return hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavioural subset of `composer.json`. Keeps the keys that
|
||||
* actually move test outcomes (autoload, require, extra,
|
||||
* repositories, minimum-stability, platform / allow-plugins
|
||||
* config) and drops cosmetic ones (description, keywords,
|
||||
* scripts, authors, funding, homepage, support). Falls back to
|
||||
* a raw hash on parse errors so any change still rebuilds.
|
||||
*/
|
||||
private static function composerJsonHash(string $projectRoot): ?string
|
||||
{
|
||||
$path = $projectRoot.'/composer.json';
|
||||
@ -403,15 +404,6 @@ final readonly class Fingerprint
|
||||
return $json === false ? null : hash('xxh128', $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavioural subset of `composer.lock`. For every package in
|
||||
* `packages` and `packages-dev`, keeps version + dist/source
|
||||
* reference (commit SHA — catches dev-branch updates that don't
|
||||
* bump the version string) + autoload(-dev) + extra (Laravel
|
||||
* package discovery). Drops install timestamps, dist URLs,
|
||||
* support links, descriptions, etc. — none of which change what
|
||||
* code runs.
|
||||
*/
|
||||
private static function composerLockHash(string $projectRoot): ?string
|
||||
{
|
||||
$path = $projectRoot.'/composer.lock';
|
||||
@ -492,12 +484,6 @@ final readonly class Fingerprint
|
||||
return is_string($reference) ? $reference : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sorts associative arrays by key so semantically
|
||||
* equivalent JSON produces the same hash regardless of key
|
||||
* ordering. Lists (numeric arrays) keep their order — they're
|
||||
* meaningful in `repositories`, `autoload.files`, etc.
|
||||
*/
|
||||
private static function sortRecursively(mixed &$value): void
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
@ -537,16 +523,8 @@ final readonly class Fingerprint
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic hash of the extensions the project actually depends on —
|
||||
* the `ext-*` entries in composer.json's `require` / `require-dev`. An
|
||||
* incidental extension loaded on the developer's machine (or on CI) but
|
||||
* not declared as a dependency can't affect correctness of the test
|
||||
* suite, so we ignore it here to keep the drift signal quiet.
|
||||
*
|
||||
* Declared extensions that aren't currently loaded record as `missing`,
|
||||
* which is itself a drift signal worth surfacing.
|
||||
*/
|
||||
// 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);
|
||||
@ -567,15 +545,7 @@ final readonly class Fingerprint
|
||||
return hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension names (without the `ext-` prefix) that appear as keys under
|
||||
* `require` or `require-dev` in the project's composer.json. Returns
|
||||
* an empty list when composer.json is missing / unreadable / malformed,
|
||||
* so the environmental fingerprint stays stable in those cases rather
|
||||
* than flapping.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
/** @return list<string> */
|
||||
private static function declaredExtensions(string $projectRoot): array
|
||||
{
|
||||
$path = $projectRoot.'/composer.json';
|
||||
|
||||
@ -11,122 +11,47 @@ use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* File-level Test Impact Analysis graph.
|
||||
*
|
||||
* Persists the mapping `test_file → set<source_file>` so that subsequent runs
|
||||
* can skip tests whose dependencies have not changed. Paths are stored relative
|
||||
* to the project root and source files are deduplicated via an index so that
|
||||
* the on-disk JSON stays compact for large suites.
|
||||
* 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
|
||||
{
|
||||
/**
|
||||
* Relative path of each known source file, indexed by numeric id.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
/** @var array<int, string> */
|
||||
private array $files = [];
|
||||
|
||||
/**
|
||||
* Reverse lookup: source file → numeric id.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
/** @var array<string, int> */
|
||||
private array $fileIds = [];
|
||||
|
||||
/**
|
||||
* Edges: test file (relative) → list of source file ids.
|
||||
*
|
||||
* @var array<string, array<int, int>>
|
||||
*/
|
||||
/** @var array<string, array<int, int>> */
|
||||
private array $edges = [];
|
||||
|
||||
/**
|
||||
* Table edges: test file (relative) → list of lowercase SQL table
|
||||
* names the test queried during record. Populated from the
|
||||
* Recorder's `perTestTables()` snapshot; consumed at replay time
|
||||
* to do surgical invalidation when a migration changes — the
|
||||
* test only re-runs if its set intersects the tables the changed
|
||||
* migration touches. Empty for tests that never hit the DB, which
|
||||
* is exactly why those tests stay unaffected by migration edits.
|
||||
*
|
||||
* Unlike `$edges`, we store names rather than ids: the table
|
||||
* universe is small (hundreds at most on a giant app), storing
|
||||
* strings keeps the on-disk graph diff-readable, and the lookup
|
||||
* cost is negligible compared to the per-file ids used above.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
/** @var array<string, array<int, string>> */
|
||||
private array $testTables = [];
|
||||
|
||||
/**
|
||||
* Inertia page component edges: test file (relative) → list of
|
||||
* component names the test server-side rendered (whatever was
|
||||
* passed to `Inertia::render($component, …)`). Populated from
|
||||
* `Recorder::perTestInertiaComponents()`; consumed at replay time
|
||||
* so an edit to `resources/js/Pages/Users/Show.vue` only invalidates
|
||||
* tests that rendered `Users/Show`. Same string-keyed shape as
|
||||
* `$testTables` for the same diff-readable reasons.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
/** @var array<string, array<int, string>> */
|
||||
private array $testInertiaComponents = [];
|
||||
|
||||
/**
|
||||
* Inverted JS dependency map: project-relative source path under
|
||||
* `resources/js/**` → list of Inertia page components that
|
||||
* transitively import it. Populated at record time by
|
||||
* `JsModuleGraph::build()` (Vite module graph via Node helper,
|
||||
* with a PHP fallback). Replay uses this to route a
|
||||
* `Components/Button.vue` edit directly to the pages that depend
|
||||
* on it, intersecting against `$testInertiaComponents` for
|
||||
* surgical invalidation.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
/** @var array<string, array<int, string>> */
|
||||
private array $jsFileToComponents = [];
|
||||
|
||||
/**
|
||||
* Environment fingerprint captured at record time.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
/** @var array<string, mixed> */
|
||||
private array $fingerprint = [];
|
||||
|
||||
/**
|
||||
* Per-branch baselines. Each branch independently tracks:
|
||||
* - `sha` — last HEAD at which `--tia` ran on this branch
|
||||
* - `tree` — content hashes of modified files at that point
|
||||
* - `results` — per-test status + message + time
|
||||
*
|
||||
* Graph edges (test → source) stay shared across branches because
|
||||
* structure doesn't change per branch. Only run-state is per-branch so
|
||||
* a failing test on one branch doesn't poison another branch's replay.
|
||||
*
|
||||
* @var array<string, array{
|
||||
* sha: ?string,
|
||||
* tree: array<string, string>,
|
||||
* results: array<string, array{status: int, message: string, time: float, assertions?: int}>
|
||||
* results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
|
||||
* }>
|
||||
*/
|
||||
private array $baselines = [];
|
||||
|
||||
/**
|
||||
* Canonicalised project root. Resolved through `realpath()` so paths
|
||||
* captured by coverage drivers (always real filesystem targets) match
|
||||
* regardless of whether the user's CWD is a symlink or has trailing
|
||||
* separators.
|
||||
*/
|
||||
// Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
|
||||
private readonly string $projectRoot;
|
||||
|
||||
/**
|
||||
* Cached project-relative test files that contain at least one test in the
|
||||
* `arch` group.
|
||||
*
|
||||
* @var array<string, true>|null
|
||||
*/
|
||||
/** @var array<string, true>|null */
|
||||
private ?array $archTestFiles = null;
|
||||
|
||||
public function __construct(string $projectRoot)
|
||||
@ -136,9 +61,6 @@ final class Graph
|
||||
$this->projectRoot = $real !== false ? $real : $projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that a test file depends on the given source file.
|
||||
*/
|
||||
public function link(string $testFile, string $sourceFile): void
|
||||
{
|
||||
$testRel = $this->relative($testFile);
|
||||
@ -158,20 +80,11 @@ final class Graph
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of test files whose dependencies intersect $changedFiles.
|
||||
*
|
||||
* Two resolution paths:
|
||||
* 1. **Coverage edges** — test depends on a PHP source file that changed.
|
||||
* 2. **Watch patterns** — a non-PHP file (JS, CSS, config, …) matches a
|
||||
* glob that maps to a test directory; every test under that directory
|
||||
* is affected.
|
||||
*
|
||||
* @param array<int, string> $changedFiles Absolute or relative paths.
|
||||
* @return array<int, string> Relative test file paths.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function affected(array $changedFiles): array
|
||||
{
|
||||
// Normalise all changed paths once.
|
||||
$normalised = [];
|
||||
|
||||
foreach ($changedFiles as $file) {
|
||||
@ -184,15 +97,9 @@ final class Graph
|
||||
|
||||
$affectedSet = [];
|
||||
|
||||
// Migration changes don't flow through the coverage-edge path —
|
||||
// `RefreshDatabase` in every test's `setUp()` means every test
|
||||
// has an edge to every migration, so step 1 would re-run the
|
||||
// whole DB-touching suite on any migration edit. Route them
|
||||
// separately: static-parse the migration source, union the
|
||||
// referenced tables, and match tests whose recorded query
|
||||
// footprint intersects that set. Missed files (rare: migrations
|
||||
// with pure raw SQL or dynamic names) fall back to the watch
|
||||
// pattern below.
|
||||
// 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 = [];
|
||||
|
||||
@ -237,16 +144,22 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Inertia page-component routing. When a page under
|
||||
// `resources/js/Pages/` changes, map it to the component name
|
||||
// Inertia would use (the path relative to `Pages/`, extension
|
||||
// stripped) and intersect with the captured component edges.
|
||||
// Only invalidates tests that actually rendered the page.
|
||||
// Pages with no captured edges (never rendered during record,
|
||||
// brand-new on this branch) fall through to the watch-pattern
|
||||
// fallback — safe over-run. Pages handled here are tracked in
|
||||
// `$preciselyHandledPages` so the watch broadcast and JS-dep
|
||||
// lookup don't re-route them.
|
||||
// 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) {
|
||||
if (! $this->isGlobalFrontendRuntimePath($rel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_keys($this->testInertiaComponents) as $testFile) {
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
|
||||
$globalFrontendRuntimeFiles[$rel] = true;
|
||||
}
|
||||
|
||||
$changedComponents = [];
|
||||
$preciselyHandledPages = [];
|
||||
|
||||
@ -263,17 +176,13 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Shared JS files (Components, Layouts, composables, etc.)
|
||||
// aren't Inertia pages but pages depend on them transitively.
|
||||
// `$jsFileToComponents` was computed at record time by walking
|
||||
// Vite's module graph, so a change to
|
||||
// `resources/js/Components/Button.vue` resolves directly to
|
||||
// the set of page components that import it. Union those into
|
||||
// `$changedComponents`. Files that aren't in the JS dep map
|
||||
// fall through to the watch pattern below — same safety-net
|
||||
// path the Inertia block above uses for unresolved pages.
|
||||
// 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])) {
|
||||
continue;
|
||||
}
|
||||
if (isset($preciselyHandledPages[$rel])) {
|
||||
continue;
|
||||
}
|
||||
@ -295,23 +204,14 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Orphan detection for NEW JS files. `$jsFileToComponents` is
|
||||
// a record-time snapshot; files added since (a fresh Vue
|
||||
// component, a new shared util, etc.) are absent from it.
|
||||
// Today the broad watch pattern catches them — correct but
|
||||
// pessimistic: a JS file that literally no page imports
|
||||
// would still invalidate the entire browser dir.
|
||||
//
|
||||
// Fix: for each new JS file in the changed set, ask Vite
|
||||
// (strict mode — no PHP fallback) which pages transitively
|
||||
// import it. If none → orphan, suppress the broadcast. If
|
||||
// some → precise union with their tests' components. The
|
||||
// Node helper is the only resolver trustworthy enough to
|
||||
// honour a *negative* answer (the PHP parser can silently
|
||||
// miss custom aliases). When Node is unreachable we leave
|
||||
// the files alone and let the watch pattern do its job.
|
||||
// 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])) {
|
||||
continue;
|
||||
}
|
||||
if (isset($preciselyHandledPages[$rel])) {
|
||||
continue;
|
||||
}
|
||||
@ -331,12 +231,8 @@ final class Graph
|
||||
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||
|
||||
if ($freshMap === null) {
|
||||
// Vite resolver was unavailable (Node missing, cold-start
|
||||
// timeout, vite.config refused to load). Falling back to
|
||||
// the broad watch pattern is the correct call, but
|
||||
// doing so silently can make a slow replay feel
|
||||
// inexplicable — surface a single line so the user
|
||||
// knows precision was downgraded for these files.
|
||||
// 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(
|
||||
@ -349,9 +245,7 @@ final class Graph
|
||||
$pages = $freshMap[$rel] ?? [];
|
||||
|
||||
if ($pages === []) {
|
||||
// Vite itself says nothing imports this file.
|
||||
// Safe to skip — mark handled so the watch
|
||||
// pattern below doesn't re-broadcast it.
|
||||
// Vite confirms no page imports this file — suppress the watch broadcast.
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
|
||||
continue;
|
||||
@ -388,9 +282,8 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Coverage-edge lookup (PHP → PHP). Migrations are already
|
||||
// handled above; skipping them here prevents their always-on
|
||||
// coverage edges from invalidating the whole DB suite.
|
||||
// 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;
|
||||
@ -410,28 +303,18 @@ final class Graph
|
||||
$absolute = $this->projectRoot.'/'.$rel;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// Deleted source file unknown to the graph — can't affect
|
||||
// any test because no edge ever pointed to it.
|
||||
// Deleted source file unknown to the graph — no edge ever pointed to it.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Source PHP file unknown to the graph — might be a new file
|
||||
// that only exists on this branch (graph inherited from main).
|
||||
// Only use the sibling heuristic for files that commonly
|
||||
// participate in framework discovery / bootstrap. Ordinary new
|
||||
// classes, enums, DTOs, services, etc. should not re-run sibling
|
||||
// tests just because they live in the same directory.
|
||||
if ($this->usesSiblingHeuristicForUnknownPhp($rel)) {
|
||||
$unknownSourceDirs[dirname($rel)] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Architecture tests inspect source structure by namespace / path rather
|
||||
// than by executing the inspected files. A new enum/class can therefore
|
||||
// fail an Arch expectation without ever producing a coverage edge. Keep
|
||||
// this fallback narrow: only tests in Pest's `arch` group run, not the
|
||||
// suite.
|
||||
// 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)) {
|
||||
@ -454,11 +337,8 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown Blade files can still be routed precisely when another
|
||||
// recorded Blade view statically references them (`@include`,
|
||||
// `@extends`, `<x-alert />`, etc.). Walk the source-level Blade graph
|
||||
// upward to rendered ancestors and invalidate tests that rendered those
|
||||
// ancestors instead of broadcasting every Blade edit to the whole suite.
|
||||
// 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])) {
|
||||
@ -480,29 +360,13 @@ final class Graph
|
||||
|
||||
$staticallyHandledBlade[$rel] = true;
|
||||
} elseif ($this->isBladeComponentPath($rel)) {
|
||||
// Anonymous Blade components are leaf templates. If nothing in
|
||||
// the project statically renders the component, treat it like an
|
||||
// orphan rather than running the full suite.
|
||||
// Anonymous component with no static usages — treat as orphan rather than broadcasting.
|
||||
$staticallyHandledBlade[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Watch-pattern lookup — fallback for files we don't have
|
||||
// precise edges for. When a file is already in `$fileIds` step
|
||||
// 1 resolved it surgically; broadcasting it again through the
|
||||
// watch pattern would re-add every test the pattern maps to,
|
||||
// defeating the point of recording the edge in the first place.
|
||||
// Blade templates captured via Laravel's view composer are the
|
||||
// motivating case — we want their specific tests, not every
|
||||
// feature test. Migrations whose static parse yielded nothing
|
||||
// (exotic syntax, raw SQL) are funneled back in here too so
|
||||
// broad invalidation still kicks in for edge cases we can't
|
||||
// parse.
|
||||
// Exclude paths that were already routed precisely through
|
||||
// either the Inertia page-component path or the shared-JS
|
||||
// dependency path. Broadcasting them again via the watch
|
||||
// pattern would re-add every test the pattern maps to,
|
||||
// defeating the surgical match.
|
||||
// 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])) {
|
||||
@ -516,8 +380,7 @@ 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, so it can't affect any test.
|
||||
// Deleted file unknown to the graph — no edge ever pointed to it.
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -535,22 +398,9 @@ final class Graph
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
|
||||
// 3. Sibling heuristic for unknown source files.
|
||||
//
|
||||
// When a PHP source file is unknown to the graph (no test depends on
|
||||
// it), it is either genuinely untested OR it was added on a branch
|
||||
// whose graph was inherited from another branch (e.g. main). In the
|
||||
// latter case the graph simply never saw the file.
|
||||
//
|
||||
// To avoid silent misses for framework-discovered files: find tests
|
||||
// that already cover ANY file in the same directory. If
|
||||
// `app/Listeners/SendWelcomeEmail.php` is unknown but neighbouring
|
||||
// listeners are covered by a mail-flow test, run that test — it likely
|
||||
// exercises the same discovery surface.
|
||||
//
|
||||
// This over-runs slightly (sibling may be unrelated) but never
|
||||
// under-runs. And once the test executes, its coverage captures the
|
||||
// new file → graph self-heals for next run.
|
||||
// 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])) {
|
||||
@ -576,9 +426,6 @@ final class Graph
|
||||
return array_keys($affectedSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the given test file has any recorded dependencies.
|
||||
*/
|
||||
public function knowsTest(string $testFile): bool
|
||||
{
|
||||
$rel = $this->relative($testFile);
|
||||
@ -586,9 +433,7 @@ final class Graph
|
||||
return $rel !== null && isset($this->edges[$rel]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> All project-relative test files the graph knows.
|
||||
*/
|
||||
/** @return array<int, string> */
|
||||
public function allTestFiles(): array
|
||||
{
|
||||
return array_keys($this->edges);
|
||||
@ -610,12 +455,6 @@ final class Graph
|
||||
return $this->fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SHA the given branch last ran against, or falls back to
|
||||
* `$fallbackBranch` (typically `main`) when this branch has no baseline
|
||||
* yet. That way a freshly-created feature branch inherits main's
|
||||
* baseline on its first run.
|
||||
*/
|
||||
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
@ -629,7 +468,7 @@ final class Graph
|
||||
$this->baselines[$branch]['sha'] = $sha;
|
||||
}
|
||||
|
||||
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0): void
|
||||
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['results'][$testId] = [
|
||||
@ -638,14 +477,16 @@ final class Graph
|
||||
'time' => $time,
|
||||
'assertions' => $assertions,
|
||||
];
|
||||
|
||||
if ($file !== null) {
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
$this->baselines[$branch]['results'][$testId]['file'] = $rel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached assertion count for a test, or `null` if unknown.
|
||||
* Callers use this to feed `addToAssertionCount()` at replay time so
|
||||
* the "Tests: N passed (M assertions)" banner matches the recorded run
|
||||
* instead of defaulting to 1 assertion per test.
|
||||
*/
|
||||
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
@ -684,6 +525,58 @@ final class Graph
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function failedOrErroredTestFiles(string $branch, string $fallbackBranch = 'main'): array
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
$files = [];
|
||||
|
||||
foreach ($baseline['results'] as $result) {
|
||||
$status = $result['status'] ?? null;
|
||||
|
||||
if ($status !== 7 && $status !== 8) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
$files[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($files);
|
||||
}
|
||||
|
||||
public function hasUnlocatedFailuresOrErrors(string $branch, string $fallbackBranch = 'main'): bool
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
foreach ($baseline['results'] as $result) {
|
||||
$status = $result['status'] ?? null;
|
||||
|
||||
if ($status !== 7 && $status !== 8) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '' || $this->relative($file) === null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $tree project-relative path → content hash
|
||||
*/
|
||||
@ -693,14 +586,7 @@ final class Graph
|
||||
$this->baselines[$branch]['tree'] = $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipes cached per-test results for the given branch. Edges and tree
|
||||
* snapshot stay intact — the graph still describes the code correctly,
|
||||
* only the "what happened last time" data is reset. Used on
|
||||
* environmental fingerprint drift: the edges were recorded elsewhere
|
||||
* (e.g. CI) so they're still valid, but the results aren't trustworthy
|
||||
* on this machine until the tests re-run here.
|
||||
*/
|
||||
// Edges and tree snapshot stay intact; only the run-state is reset.
|
||||
public function clearResults(string $branch): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
@ -716,7 +602,7 @@ final class Graph
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int}>}
|
||||
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
|
||||
*/
|
||||
private function baselineFor(string $branch, string $fallbackBranch): array
|
||||
{
|
||||
@ -739,9 +625,6 @@ final class Graph
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces edges for the given test files. Used during a partial record
|
||||
* run so that existing edges for other tests are preserved.
|
||||
*
|
||||
* @param array<string, array<int, string>> $testToFiles
|
||||
*/
|
||||
public function replaceEdges(array $testToFiles): void
|
||||
@ -765,12 +648,6 @@ final class Graph
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces table edges for the given test files. Table names are
|
||||
* lowercased + deduplicated; the input comes straight from the
|
||||
* Recorder's `perTestTables()` snapshot. Tests absent from the
|
||||
* input keep their existing table set (same partial-update policy
|
||||
* as `replaceEdges`).
|
||||
*
|
||||
* @param array<string, array<int, string>> $testToTables
|
||||
*/
|
||||
public function replaceTestTables(array $testToTables): void
|
||||
@ -800,11 +677,6 @@ final class Graph
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces Inertia component edges for the given test files. Names
|
||||
* preserve case (they're identifiers like `Users/Show`, not
|
||||
* user-supplied strings) but duplicates are collapsed. Same
|
||||
* partial-update policy as `replaceTestTables`.
|
||||
*
|
||||
* @param array<string, array<int, string>> $testToComponents
|
||||
*/
|
||||
public function replaceTestInertiaComponents(array $testToComponents): void
|
||||
@ -831,16 +703,8 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Empty input is treated as a resolver failure (not "no JS pages") — keep the previous map.
|
||||
/**
|
||||
* Replaces the whole JS dep map. Called at record time with the
|
||||
* output of `JsModuleGraph::build()`. Empty input is treated as a
|
||||
* resolver failure (Node missing, Vite refused to load, transient
|
||||
* `npm install`) rather than a legitimate "no JS pages" signal —
|
||||
* we keep the previous map. Stale entries for genuinely-deleted
|
||||
* pages are harmless because deleted files never enter the
|
||||
* changed set; over-broadcasting every JS edit through the watch
|
||||
* pattern after a flaky Node run would be a real regression.
|
||||
*
|
||||
* @param array<string, array<int, string>> $fileToComponents
|
||||
*/
|
||||
public function replaceJsFileToComponents(array $fileToComponents): void
|
||||
@ -877,23 +741,11 @@ final class Graph
|
||||
$this->jsFileToComponents = $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects under Laravel conventionally keep migrations at
|
||||
* `database/migrations/`. We recognise the directory as a prefix
|
||||
* so nested subdirectories (a pattern some teams use for grouping
|
||||
* — `database/migrations/tenant/`, `database/migrations/archived/`)
|
||||
* are still routed through the table-intersection path.
|
||||
*/
|
||||
private function isMigrationPath(string $rel): bool
|
||||
{
|
||||
return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown PHP files have no historical edge yet. Keep sibling fan-out only
|
||||
* for framework-discovered / boot-loaded conventions where adding a file can
|
||||
* change behaviour without another source file changing too.
|
||||
*/
|
||||
private function usesSiblingHeuristicForUnknownPhp(string $rel): bool
|
||||
{
|
||||
static $prefixes = [
|
||||
@ -905,6 +757,14 @@ final class Graph
|
||||
'app/Console/Commands/',
|
||||
'app/Mail/',
|
||||
'app/Notifications/',
|
||||
'app/Nova/Actions/',
|
||||
'app/Nova/Dashboards/',
|
||||
'app/Nova/Lenses/',
|
||||
'app/Nova/Metrics/',
|
||||
'app/Nova/Policies/',
|
||||
'app/Nova/Resources/',
|
||||
'app/Projectors/',
|
||||
'app/Reactors/',
|
||||
'database/factories/',
|
||||
'database/seeders/',
|
||||
];
|
||||
@ -1204,15 +1064,7 @@ final class Graph
|
||||
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `$rel` relative to the project root and extracts the
|
||||
* tables it declares via `Schema::create/table/drop/rename`.
|
||||
* Empty on missing/unreadable files or when the parser finds
|
||||
* nothing — the caller escalates those cases to the watch
|
||||
* pattern safety net.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
/** @return list<string> */
|
||||
private function tablesForMigration(string $rel): array
|
||||
{
|
||||
$absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel;
|
||||
@ -1230,21 +1082,7 @@ final class Graph
|
||||
return TableExtractor::fromMigrationSource($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a project-relative path to its Inertia component name if it
|
||||
* lives under the project's pages directory with a recognised
|
||||
* framework extension. Returns null otherwise so callers can
|
||||
* cheaply ignore non-page files. Matches Inertia's resolver
|
||||
* convention: strip the pages prefix, strip the extension, preserve
|
||||
* the remaining slashes (`Users/Show.vue` → `Users/Show`).
|
||||
*
|
||||
* Both `resources/js/Pages/` (the classic Inertia-Vue convention)
|
||||
* and `resources/js/pages/` (the Laravel React starter kit, and
|
||||
* other lowercase-by-default setups) are accepted — paths from
|
||||
* git are case-sensitive on Linux, so we must match the exact
|
||||
* casing used by the project rather than picking one and forcing
|
||||
* the other to fall through to the broad watch pattern.
|
||||
*/
|
||||
// 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) {
|
||||
@ -1273,13 +1111,27 @@ final class Graph
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any test's component set contains `$component`. Used to
|
||||
* decide between precise edge matching and watch-pattern fallback
|
||||
* for a changed Inertia page file.
|
||||
*
|
||||
* @param array<string, array<int, string>> $edges
|
||||
*/
|
||||
private function isGlobalFrontendRuntimePath(string $rel): bool
|
||||
{
|
||||
if (! str_starts_with($rel, 'resources/js/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tail = substr($rel, strlen('resources/js/'));
|
||||
$dot = strrpos($tail, '.');
|
||||
|
||||
if ($dot === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$name = substr($tail, 0, $dot);
|
||||
$extension = substr($tail, $dot + 1);
|
||||
|
||||
return in_array($extension, ['js', 'jsx', 'ts', 'tsx', 'vue', 'svelte'], true)
|
||||
&& in_array($name, ['App', 'app', 'bootstrap', 'echo', 'favicon'], true);
|
||||
}
|
||||
|
||||
/** @param array<string, array<int, string>> $edges */
|
||||
private function anyTestUses(array $edges, string $component): bool
|
||||
{
|
||||
foreach ($edges as $components) {
|
||||
@ -1291,11 +1143,6 @@ final class Graph
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops edges whose test file no longer exists on disk. Prevents the graph
|
||||
* from keeping stale entries for deleted / renamed tests that would later
|
||||
* be flagged as affected and confuse PHPUnit's discovery.
|
||||
*/
|
||||
public function pruneMissingTests(): void
|
||||
{
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
@ -1319,12 +1166,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a graph from its JSON representation. Returns `null` when
|
||||
* the payload is missing, unreadable, or schema-incompatible. Separated
|
||||
* from transport (state backend, file, etc.) so tests can feed bytes
|
||||
* directly without touching disk.
|
||||
*/
|
||||
public static function decode(string $json, string $projectRoot): ?self
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
@ -1412,12 +1253,6 @@ final class Graph
|
||||
return $graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialises the graph to its JSON on-disk form. Returns `null` if the
|
||||
* payload can't be encoded (extremely rare — pathological UTF-8 only).
|
||||
* Persistence is the caller's responsibility: write the returned bytes
|
||||
* through whatever `State` implementation is in play.
|
||||
*/
|
||||
public function encode(): ?string
|
||||
{
|
||||
$payload = [
|
||||
@ -1436,15 +1271,8 @@ final class Graph
|
||||
return $json === false ? null : $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a path to be relative to the project root; returns `null` for
|
||||
* paths we should ignore (outside the project, unknown, virtual, vendor).
|
||||
*
|
||||
* Accepts both absolute paths (from Xdebug/PCOV coverage) and
|
||||
* project-relative paths (from `git diff`) — we normalise without relying
|
||||
* on `realpath()` of relative paths because the current working directory
|
||||
* is not guaranteed to be the project root.
|
||||
*/
|
||||
// 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') {
|
||||
@ -1471,12 +1299,9 @@ final class Graph
|
||||
return null;
|
||||
}
|
||||
|
||||
// Always normalise to forward slashes. Windows' native separator
|
||||
// would otherwise produce keys that never match paths reported
|
||||
// by `git` (which always uses forward slashes).
|
||||
// Always forward slashes — git always uses them; Windows backslashes would never match.
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||
} else {
|
||||
// Normalise directory separators and strip any "./" prefix.
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
|
||||
|
||||
while (str_starts_with($relative, './')) {
|
||||
@ -1484,10 +1309,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor packages are pinned by composer.lock. Any upgrade bumps the
|
||||
// fingerprint and invalidates the graph wholesale, so there is no
|
||||
// reason to track individual vendor files — doing so inflates the
|
||||
// graph by orders of magnitude on Laravel-style projects.
|
||||
if (str_starts_with($relative, 'vendor/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -12,23 +12,29 @@ use Symfony\Component\Process\Process;
|
||||
* `resources/js/**` — for every source file, the list of Inertia page
|
||||
* components that transitively import it.
|
||||
*
|
||||
* Tries two resolvers in order:
|
||||
* 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.
|
||||
*
|
||||
* 1. **Node helper** (`bin/pest-tia-vite-deps.mjs`). Spins up 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 same graph Vite itself would use.
|
||||
* Two latency mitigations:
|
||||
*
|
||||
* 2. **PHP fallback** (`JsImportParser`). Regex-scans ES imports
|
||||
* and resolves `@/` / `~/` aliases manually. Strictly less
|
||||
* precise — anything it can't resolve is skipped, leaving the
|
||||
* caller to fall back to the broad watch pattern. Only kicks in
|
||||
* when the Node helper is unusable (no Node on PATH, no Vite
|
||||
* installed, vite.config fails to load).
|
||||
* 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.
|
||||
*
|
||||
* Callers invoke this at record time; results are persisted into the
|
||||
* graph so replay never re-runs the resolver. On stale-map detection
|
||||
* 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
|
||||
@ -37,36 +43,94 @@ final class JsModuleGraph
|
||||
{
|
||||
private const int NODE_TIMEOUT_SECONDS = 25;
|
||||
|
||||
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
|
||||
*/
|
||||
public static function build(string $projectRoot): array
|
||||
{
|
||||
$viaNode = self::tryNodeHelper($projectRoot);
|
||||
$result = self::resolve($projectRoot);
|
||||
|
||||
if ($viaNode !== null) {
|
||||
return $viaNode;
|
||||
}
|
||||
|
||||
return JsImportParser::parse($projectRoot);
|
||||
return $result ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant — only runs the Node helper, never falls back to
|
||||
* the PHP parser. Returns null when Node isn't available or Vite
|
||||
* won't load.
|
||||
* 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"). The PHP fallback is conservative on positives but can
|
||||
* miss imports that rely on custom aliases or plugins — negative
|
||||
* results from it cannot be trusted for orphan pruning.
|
||||
* (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
|
||||
{
|
||||
return self::tryNodeHelper($projectRoot);
|
||||
return self::resolve($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -97,7 +161,100 @@ final class JsModuleGraph
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function tryNodeHelper(string $projectRoot): ?array
|
||||
private static function resolve(string $projectRoot): ?array
|
||||
{
|
||||
$fingerprint = self::fingerprint($projectRoot);
|
||||
|
||||
if ($fingerprint !== null) {
|
||||
$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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::parseNodeOutput($process->getOutput());
|
||||
}
|
||||
|
||||
private static function buildNodeProcess(string $projectRoot): ?Process
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
return null;
|
||||
@ -135,14 +292,17 @@ final class JsModuleGraph
|
||||
|
||||
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot, $env);
|
||||
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function parseNodeOutput(string $output): ?array
|
||||
{
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($process->getOutput(), true);
|
||||
$decoded = json_decode($output, true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
@ -176,6 +336,200 @@ 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)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
|
||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stat = @stat($path);
|
||||
$bytes = @file_get_contents($path);
|
||||
|
||||
$parts[] = 'config:'.$name
|
||||
.':'.($stat === false ? '0' : (string) $stat['mtime'])
|
||||
.':'.($stat === false ? '0' : (string) $stat['size'])
|
||||
.':'.($bytes === false ? '' : hash('sha256', $bytes));
|
||||
}
|
||||
|
||||
foreach (['Pages', 'pages'] as $dir) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
|
||||
$parts[] = 'pagesDir:'.$dir;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js';
|
||||
|
||||
if (is_dir($jsRoot)) {
|
||||
$entries = [];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
||||
);
|
||||
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = $file->getPathname()
|
||||
.':'.$file->getSize()
|
||||
.':'.$file->getMTime();
|
||||
}
|
||||
|
||||
sort($entries);
|
||||
|
||||
$parts[] = 'js:'.hash('sha256', implode("\n", $entries));
|
||||
}
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function readCache(string $projectRoot, string $fingerprint): ?array
|
||||
{
|
||||
$path = self::cachePath($projectRoot);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$graph = $decoded['graph'] ?? null;
|
||||
|
||||
if (! is_array($graph)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($graph as $key => $value) {
|
||||
if (! is_string($key) || ! is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($value as $name) {
|
||||
if (is_string($name) && $name !== '') {
|
||||
$names[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$out[$key] = $names;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>> $graph
|
||||
*/
|
||||
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
|
||||
{
|
||||
$path = self::cachePath($projectRoot);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'fingerprint' => $fingerprint,
|
||||
'graph' => $graph,
|
||||
]);
|
||||
|
||||
if ($payload === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
|
||||
|
||||
if (@file_put_contents($tmp, $payload) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
private static function cachePath(string $projectRoot): string
|
||||
{
|
||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private static function hasViteConfig(string $projectRoot): bool
|
||||
{
|
||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||
|
||||
@ -8,119 +8,49 @@ use Pest\TestSuite;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* Captures per-test file coverage using the PCOV driver.
|
||||
*
|
||||
* Acts as a singleton because PCOV has a single global collection state and
|
||||
* the recorder is wired into PHPUnit through two distinct subscribers
|
||||
* (`Prepared` / `Finished`) that must share context.
|
||||
* 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
|
||||
{
|
||||
/**
|
||||
* Test file currently being recorded, or `null` when idle.
|
||||
*/
|
||||
private ?string $currentTestFile = null;
|
||||
|
||||
/**
|
||||
* Aggregated map: absolute test file → set<absolute source file>.
|
||||
*
|
||||
* @var array<string, array<string, true>>
|
||||
*/
|
||||
/** @var array<string, array<string, true>> */
|
||||
private array $perTestFiles = [];
|
||||
|
||||
/**
|
||||
* Aggregated map: absolute test file → set<lowercase table name>.
|
||||
* Populated by `TableTracker` from `DB::listen` callbacks; consumed
|
||||
* at record finalize to populate the graph's `$testTables` edges
|
||||
* that drive migration-change impact analysis.
|
||||
*
|
||||
* @var array<string, array<string, true>>
|
||||
*/
|
||||
/** @var array<string, array<string, true>> */
|
||||
private array $perTestTables = [];
|
||||
|
||||
/**
|
||||
* Aggregated map: absolute test file → set<Inertia component name>.
|
||||
* Populated by `InertiaEdges` from Inertia responses observed at
|
||||
* request-handled time; consumed at record finalize to populate
|
||||
* the graph's per-test component edges that drive Vue / React
|
||||
* page-file impact analysis.
|
||||
*
|
||||
* @var array<string, array<string, true>>
|
||||
*/
|
||||
/** @var array<string, array<string, true>> */
|
||||
private array $perTestInertiaComponents = [];
|
||||
|
||||
/**
|
||||
* Set of absolute test files whose class hierarchy uses one of
|
||||
* Laravel's database-resetting traits (`RefreshDatabase`,
|
||||
* `DatabaseMigrations`, `DatabaseTransactions`). Captured at
|
||||
* `beginTest` so the finalize path can augment their table edges
|
||||
* even when seeders / pre-test DML fired before `TableTracker`
|
||||
* armed.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
/** @var array<string, true> */
|
||||
private array $perTestUsesDatabase = [];
|
||||
|
||||
/**
|
||||
* Cached class → test file resolution.
|
||||
*
|
||||
* @var array<string, string|null>
|
||||
*/
|
||||
/** @var array<string, string|null> */
|
||||
private array $classFileCache = [];
|
||||
|
||||
/**
|
||||
* Cached class → "uses Laravel DB trait" introspection result.
|
||||
*
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
/** @var array<string, bool> */
|
||||
private array $classUsesDatabaseCache = [];
|
||||
|
||||
/**
|
||||
* Reverse map of project-local source file → list of class /
|
||||
* interface / trait names declared in it. Built incrementally as
|
||||
* tests run and new classes get autoloaded; consumed by
|
||||
* `linkSourceDependencies()` so a test's covered file's
|
||||
* declared classes can be walked for their interfaces, traits,
|
||||
* and parents (which the coverage driver doesn't capture
|
||||
* because interface declarations and empty traits emit no
|
||||
* executable bytecode).
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
// 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 = [];
|
||||
|
||||
/**
|
||||
* Names already folded into `$fileToClassNames`. Lets the
|
||||
* incremental refresher skip classes seen in a previous test.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
/** @var array<string, true> */
|
||||
private array $indexedClassNames = [];
|
||||
|
||||
/**
|
||||
* Cached "files this class transitively depends on (interfaces,
|
||||
* traits, parent chain, parents' interfaces and traits)" for
|
||||
* project-local class names. Avoids re-walking the same
|
||||
* hierarchy on every test that touches the same class.
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
/** @var array<string, list<string>> */
|
||||
private array $classDependencyCache = [];
|
||||
|
||||
/**
|
||||
* Cached test-file import resolution.
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
/** @var array<string, list<string>> */
|
||||
private array $testImportFileCache = [];
|
||||
|
||||
/**
|
||||
* Included-file snapshot captured at the start of the current test.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
/** @var array<string, true> */
|
||||
private array $includedFilesAtTestStart = [];
|
||||
|
||||
private bool $active = false;
|
||||
@ -148,15 +78,8 @@ final class Recorder
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
||||
// Xdebug 3+ exposes the active mode set via `xdebug_info`,
|
||||
// so we can ask directly instead of probing with a
|
||||
// start/stop pair. The probe approach used to emit
|
||||
// E_WARNING when coverage mode was off; with monitoring
|
||||
// agents (Sentry, Bugsnag) hooked into the error
|
||||
// handler stack that warning could be reported as a
|
||||
// real error. `xdebug_info('mode')` is silent and
|
||||
// returns the active modes as a list, so a presence
|
||||
// check is enough.
|
||||
// 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)) {
|
||||
@ -201,17 +124,8 @@ final class Recorder
|
||||
$this->perTestUsesDatabase[$file] = true;
|
||||
}
|
||||
|
||||
// Walk the parent-class chain and link each ancestor's defining
|
||||
// file as a source dependency of this test. Captures the common
|
||||
// `tests/TestCase.php` case (where the user's base may be
|
||||
// trait-only and have no executable lines for the coverage
|
||||
// driver to pick up), and any deeper hierarchy. Vendor parents
|
||||
// are skipped — those are pinned by `composer.lock` and don't
|
||||
// need per-test edges. Same idea applies to traits used by the
|
||||
// ancestors: a trait's body executes when the test method
|
||||
// calls into it, so coverage already captures it; we only need
|
||||
// the explicit walk for ancestors whose own bodies might be
|
||||
// empty.
|
||||
// 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);
|
||||
|
||||
@ -235,13 +149,11 @@ final class Recorder
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\stop();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \pcov\collect(\pcov\inclusive);
|
||||
$data = \pcov\collect(\pcov\all);
|
||||
} else {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \xdebug_get_code_coverage();
|
||||
// `true` resets Xdebug's internal buffer so the next `start()`
|
||||
// does not accumulate earlier tests' coverage into the current
|
||||
// one — otherwise the graph becomes progressively polluted.
|
||||
// `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
|
||||
\xdebug_stop_code_coverage(true);
|
||||
}
|
||||
|
||||
@ -258,29 +170,14 @@ final class Recorder
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
// Walk each covered class's interfaces / traits / parent chain
|
||||
// and link those files explicitly. Interface declarations have
|
||||
// no executable bytecode, so coverage drivers never emit lines
|
||||
// for them — without this walk, a signature change to an
|
||||
// interface like `Viewable` would leave the cached results of
|
||||
// every test that exercises an implementing class stale,
|
||||
// because the interface file never enters the graph through
|
||||
// the coverage path.
|
||||
// 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(array_keys($data));
|
||||
|
||||
$this->currentTestFile = null;
|
||||
$this->includedFilesAtTestStart = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an extra source-file dependency for the currently-running
|
||||
* test. Used by collaborators that capture edges the coverage driver
|
||||
* cannot see — Blade templates rendered through Laravel's view
|
||||
* factory are the motivating case (their `.blade.php` source never
|
||||
* executes directly; a cached compiled PHP file does). No-op when
|
||||
* the recorder is inactive or no test is in flight, so callers can
|
||||
* fire it unconditionally from app-level hooks.
|
||||
*/
|
||||
public function linkSource(string $sourceFile): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
@ -298,12 +195,7 @@ final class Recorder
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records source dependencies for a specific test file. Used for edges
|
||||
* captured before `Prepared` has opened the normal per-test recorder window.
|
||||
*
|
||||
* @param iterable<int, string> $sourceFiles
|
||||
*/
|
||||
/** @param iterable<int, string> $sourceFiles */
|
||||
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
@ -323,23 +215,7 @@ final class Recorder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For each project-local source file the coverage driver
|
||||
* captured for this test, finds the classes / interfaces / traits
|
||||
* declared in it and links every file in their declarative
|
||||
* hierarchy: implemented interfaces (transitive), used traits,
|
||||
* and parent classes (with their own interfaces and traits).
|
||||
*
|
||||
* Coverage drivers only record executable lines, so an interface
|
||||
* signature change (e.g. adding a return type to a `Viewable`
|
||||
* method) never registers — the interface file has no bytecode
|
||||
* to instrument. Without this walk, every class implementing the
|
||||
* interface would silently keep its stale cached result through
|
||||
* the change, even though `--parallel` (no TIA) catches the
|
||||
* incompatibility immediately.
|
||||
*
|
||||
* @param array<int, string> $coveredFiles absolute paths from coverage
|
||||
*/
|
||||
/** @param array<int, string> $coveredFiles */
|
||||
private function linkSourceDependencies(array $coveredFiles): void
|
||||
{
|
||||
if ($this->currentTestFile === null) {
|
||||
@ -361,15 +237,6 @@ final class Recorder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally folds every project-local class / interface /
|
||||
* trait declared since the last refresh into `$fileToClassNames`.
|
||||
* PHP only ever appends to its declared-symbol lists (classes
|
||||
* never get unloaded), so iterating from `$indexedClassNames`'s
|
||||
* cardinality forward is sufficient — and over a long suite this
|
||||
* is dominated by the first test, since most classes are loaded
|
||||
* by then.
|
||||
*/
|
||||
private function refreshClassMap(): void
|
||||
{
|
||||
$names = array_merge(
|
||||
@ -384,12 +251,6 @@ final class Recorder
|
||||
}
|
||||
$this->indexedClassNames[$name] = true;
|
||||
|
||||
// Names came directly from `get_declared_*`, so the
|
||||
// class/interface/trait is guaranteed loaded — but
|
||||
// `class_exists($name, false)` (no autoload) keeps the
|
||||
// string narrowed to `class-string` for static analysis
|
||||
// and the `ReflectionClass` constructor stays in its
|
||||
// documented happy path.
|
||||
if (! class_exists($name, false)
|
||||
&& ! interface_exists($name, false)
|
||||
&& ! trait_exists($name, false)) {
|
||||
@ -416,15 +277,7 @@ final class Recorder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the project-local files the named class declaratively
|
||||
* depends on: implemented interfaces (transitive), used traits,
|
||||
* and the entire parent chain (each with their own interfaces
|
||||
* and traits). Cached per class because the answer is invariant
|
||||
* across a single process.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
/** @return list<string> */
|
||||
private function classDependencies(string $className): array
|
||||
{
|
||||
if (isset($this->classDependencyCache[$className])) {
|
||||
@ -458,19 +311,11 @@ final class Recorder
|
||||
$files[$f] = true;
|
||||
};
|
||||
|
||||
// `getInterfaceNames()` is transitive — it returns interfaces
|
||||
// from parent classes and parent interfaces too — so a single
|
||||
// pass covers the whole interface graph.
|
||||
// getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices.
|
||||
foreach ($reflection->getInterfaceNames() as $iname) {
|
||||
$linkSymbol($iname);
|
||||
}
|
||||
|
||||
// Direct + ancestor traits. `getTraitNames()` doesn't recurse
|
||||
// into traits-using-traits, but that's a rare pattern in
|
||||
// application code; if a project genuinely needs it, the
|
||||
// coverage driver will pick up the executed bytecode of the
|
||||
// outer trait and the dependency walk runs against the
|
||||
// resulting class anyway.
|
||||
foreach ($reflection->getTraitNames() as $tname) {
|
||||
$linkSymbol($tname);
|
||||
}
|
||||
@ -490,16 +335,6 @@ final class Recorder
|
||||
return $this->classDependencyCache[$className] = array_keys($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records every project-local ancestor class's defining file as a
|
||||
* source dependency of the currently-running test. PCOV / Xdebug
|
||||
* record *executable lines* — a base class whose body is just
|
||||
* `class TestCase extends BaseTestCase { use CreatesApplication; }`
|
||||
* has no executable bytecode of its own, so the driver doesn't
|
||||
* emit a line for it and it never enters the graph through the
|
||||
* usual coverage path. This walk fills that gap by asking
|
||||
* reflection for each parent's file and linking it explicitly.
|
||||
*/
|
||||
private function linkAncestorFiles(string $className): void
|
||||
{
|
||||
if (! class_exists($className, false)) {
|
||||
@ -524,11 +359,6 @@ final class Recorder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Links project-local classes imported by the test file. This catches
|
||||
* declaration-only support classes / enums / interfaces that may never emit
|
||||
* executable coverage lines, and avoids relying on global autoload timing.
|
||||
*/
|
||||
private function linkImportedFiles(string $testFile): void
|
||||
{
|
||||
if ($this->currentTestFile === null) {
|
||||
@ -653,14 +483,6 @@ final class Recorder
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `$className` (or any of its ancestors) uses one of
|
||||
* Laravel's database-resetting traits. Walking up `getTraits()` is
|
||||
* necessary because Pest test classes are eval'd from the
|
||||
* generated `*.php` test file and the trait usually lives on a
|
||||
* shared `tests/TestCase.php` ancestor. Result is cached per class
|
||||
* — class hierarchies don't change within a process.
|
||||
*/
|
||||
private function classUsesDatabase(string $className): bool
|
||||
{
|
||||
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
|
||||
@ -692,14 +514,6 @@ final class Recorder
|
||||
return $this->classUsesDatabaseCache[$className] = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the currently-running test queried `$table`. Called
|
||||
* by `TableTracker` for every DML statement Laravel's `DB::listen`
|
||||
* reports; the table name has already been extracted by
|
||||
* `TableExtractor::fromSql()` so we just store it. No-op outside
|
||||
* a test window, so the callback is safe to leave armed across
|
||||
* setUp / tearDown boundaries.
|
||||
*/
|
||||
public function linkTable(string $table): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
@ -717,15 +531,6 @@ final class Recorder
|
||||
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the currently-running test server-side-rendered the
|
||||
* named Inertia component. The name is whatever
|
||||
* `Inertia::render($component, …)` was called with — typically a
|
||||
* slash-separated path like `Users/Show` that maps to
|
||||
* `resources/js/Pages/Users/Show.vue`. No-op outside a test window
|
||||
* so the underlying listener can stay armed without leaking
|
||||
* state between tests.
|
||||
*/
|
||||
public function linkInertiaComponent(string $component): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
@ -743,9 +548,7 @@ final class Recorder
|
||||
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
|
||||
*/
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function perTestFiles(): array
|
||||
{
|
||||
$out = [];
|
||||
@ -757,9 +560,7 @@ final class Recorder
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> absolute test file → sorted list of table names.
|
||||
*/
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function perTestTables(): array
|
||||
{
|
||||
$out = [];
|
||||
@ -773,9 +574,7 @@ final class Recorder
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> absolute test file → sorted list of Inertia component names.
|
||||
*/
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function perTestInertiaComponents(): array
|
||||
{
|
||||
$out = [];
|
||||
@ -789,9 +588,7 @@ final class Recorder
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, true> absolute test file → true for tests using a Laravel DB-resetting trait.
|
||||
*/
|
||||
/** @return array<string, true> */
|
||||
public function perTestUsesDatabase(): array
|
||||
{
|
||||
return $this->perTestUsesDatabase;
|
||||
@ -817,17 +614,8 @@ final class Recorder
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the file that *defines* the test class.
|
||||
*
|
||||
* Order of preference:
|
||||
* 1. Pest's generated `$__filename` static — the original `*.php` file
|
||||
* containing the `test()` calls (the eval'd class itself has no file).
|
||||
* 2. `ReflectionClass::getFileName()` — the concrete class's file. This
|
||||
* is intentionally more specific than `ReflectionMethod::getFileName()`
|
||||
* (which would return the *trait* file for methods brought in via
|
||||
* `uses SharedTestBehavior`).
|
||||
*/
|
||||
// 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)) {
|
||||
@ -853,11 +641,6 @@ final class Recorder
|
||||
return is_string($file) ? $file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all captured state. Useful for long-running hosts (daemons,
|
||||
* PHP-FPM, watchers) that invoke Pest multiple times in a single process
|
||||
* — without this, coverage from run N would bleed into run N+1.
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->currentTestFile = null;
|
||||
|
||||
@ -14,17 +14,20 @@ namespace Pest\Plugins\Tia;
|
||||
final class ResultCollector
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{status: int, message: string, time: float, assertions: int}>
|
||||
* @var array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
|
||||
*/
|
||||
private array $results = [];
|
||||
|
||||
private ?string $currentTestId = null;
|
||||
|
||||
private ?string $currentTestFile = null;
|
||||
|
||||
private ?float $startTime = null;
|
||||
|
||||
public function testPrepared(string $testId): void
|
||||
public function testPrepared(string $testId, ?string $testFile = null): void
|
||||
{
|
||||
$this->currentTestId = $testId;
|
||||
$this->currentTestFile = $testFile;
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
|
||||
@ -83,7 +86,7 @@ final class ResultCollector
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions: int}>
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
@ -102,7 +105,7 @@ final class ResultCollector
|
||||
* 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}> $results
|
||||
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
|
||||
*/
|
||||
public function merge(array $results): void
|
||||
{
|
||||
@ -115,6 +118,7 @@ final class ResultCollector
|
||||
{
|
||||
$this->results = [];
|
||||
$this->currentTestId = null;
|
||||
$this->currentTestFile = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
@ -126,6 +130,7 @@ final class ResultCollector
|
||||
public function finishTest(): void
|
||||
{
|
||||
$this->currentTestId = null;
|
||||
$this->currentTestFile = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
@ -151,5 +156,9 @@ final class ResultCollector
|
||||
'time' => $time,
|
||||
'assertions' => $existing['assertions'] ?? 0,
|
||||
];
|
||||
|
||||
if ($this->currentTestFile !== null) {
|
||||
$this->results[$this->currentTestId]['file'] = $this->currentTestFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,6 +51,53 @@ 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);
|
||||
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::removeRecursive($dir);
|
||||
}
|
||||
|
||||
private static function removeRecursive(string $dir): void
|
||||
{
|
||||
$entries = @scandir($dir);
|
||||
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (is_dir($path) && ! is_link($path)) {
|
||||
self::removeRecursive($path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on
|
||||
* Windows. Returns null if neither resolves to an existing
|
||||
|
||||
@ -45,6 +45,21 @@ final readonly class Browser implements WatchDefault
|
||||
// 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',
|
||||
'public/**/*.png',
|
||||
'public/**/*.jpg',
|
||||
'public/**/*.jpeg',
|
||||
'public/**/*.webp',
|
||||
'public/**/*.ico',
|
||||
'public/**/*.txt',
|
||||
'public/**/*.json',
|
||||
'public/**/*.xml',
|
||||
'public/hot',
|
||||
];
|
||||
|
||||
$patterns = [];
|
||||
|
||||
@ -54,8 +54,28 @@ final readonly class Laravel implements WatchDefault
|
||||
// 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],
|
||||
'app/**/*.yaml' => [$testPath],
|
||||
'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],
|
||||
|
||||
@ -58,12 +58,12 @@ final readonly class Php implements WatchDefault
|
||||
// suite.
|
||||
$testPath.'/Datasets/**/*.php' => [$testPath],
|
||||
|
||||
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
|
||||
// assertions. A fixture change can flip a test result.
|
||||
$testPath.'/Fixtures/**/*.json' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*.csv' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*.xml' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*.txt' => [$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.
|
||||
|
||||
@ -43,6 +43,12 @@ final class WatchPatterns
|
||||
*/
|
||||
private array $patterns = [];
|
||||
|
||||
private bool $enabled = false;
|
||||
|
||||
private bool $locally = false;
|
||||
|
||||
private bool $filtered = false;
|
||||
|
||||
/**
|
||||
* Probes every registered `WatchDefault` and merges the patterns of
|
||||
* those that apply. Called once during Tia plugin boot, after BootFiles
|
||||
@ -149,9 +155,42 @@ final class WatchPatterns
|
||||
return $affected;
|
||||
}
|
||||
|
||||
public function markEnabled(): void
|
||||
{
|
||||
$this->enabled = true;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function markLocally(): void
|
||||
{
|
||||
$this->locally = true;
|
||||
}
|
||||
|
||||
public function isLocally(): bool
|
||||
{
|
||||
return $this->locally;
|
||||
}
|
||||
|
||||
public function markFiltered(): void
|
||||
{
|
||||
$this->filtered = true;
|
||||
}
|
||||
|
||||
public function isFiltered(): bool
|
||||
{
|
||||
return $this->filtered;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->patterns = [];
|
||||
$this->enabled = false;
|
||||
$this->locally = false;
|
||||
$this->filtered = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -29,7 +29,7 @@ final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber
|
||||
$test = $event->test();
|
||||
|
||||
if ($test instanceof TestMethod) {
|
||||
$this->collector->testPrepared($test->className().'::'.$test->methodName());
|
||||
$this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
src/TestCaseFilters/TiaTestCaseFilter.php
Normal file
60
src/TestCaseFilters/TiaTestCaseFilter.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\TestCaseFilters;
|
||||
|
||||
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
|
||||
{
|
||||
/**
|
||||
* @param array<string, true> $affectedTestFiles Keys are project-relative test file paths.
|
||||
*/
|
||||
public function __construct(
|
||||
private string $projectRoot,
|
||||
private Graph $graph,
|
||||
private array $affectedTestFiles,
|
||||
) {}
|
||||
|
||||
public function accept(string $testCaseFilename): bool
|
||||
{
|
||||
$rel = $this->relative($testCaseFilename);
|
||||
|
||||
if ($rel === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->graph->knowsTest($rel)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isset($this->affectedTestFiles[$rel]);
|
||||
}
|
||||
|
||||
private function relative(string $path): ?string
|
||||
{
|
||||
$real = @realpath($path);
|
||||
|
||||
if ($real === false) {
|
||||
$real = $path;
|
||||
}
|
||||
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
if (! str_starts_with($real, $root)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user