mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
wip
This commit is contained in:
6
pint.json
Normal file
6
pint.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"preset": "laravel",
|
||||
"rules": {
|
||||
"Pint/phpdoc_type_annotations_only": true
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,6 @@ namespace Pest\Contracts;
|
||||
interface Restarter
|
||||
{
|
||||
/**
|
||||
* Re-execs the PHP process when conditions warrant it.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function maybeRestart(string $projectRoot, array $arguments): void;
|
||||
|
||||
@ -16,9 +16,6 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Renders the panic on the given output.
|
||||
*/
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
@ -28,9 +25,6 @@ final class NoAffectedTestsFound extends InvalidArgumentException implements Exc
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exit code to be used.
|
||||
*/
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 0;
|
||||
|
||||
@ -54,7 +54,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-';
|
||||
|
||||
/** Sentinel dropped by a recording worker without a usable coverage driver. */
|
||||
private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-';
|
||||
|
||||
public const string KEY_COVERAGE_CACHE = 'coverage.bin.gz';
|
||||
@ -67,10 +66,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
|
||||
|
||||
/** Tells workers to apply TiaTestCaseFilter instead of cache short-circuiting. */
|
||||
private const string FILTERED_GLOBAL = 'TIA_FILTERED';
|
||||
|
||||
/** Workers can't detect `--coverage` from their own argv — paratest strips it. */
|
||||
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
|
||||
|
||||
private bool $graphWritten = false;
|
||||
@ -109,10 +106,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private bool $forceRefetch = false;
|
||||
|
||||
/** Prevents fetching the same stale baseline twice after structural drift. */
|
||||
private bool $baselineFetchAttemptedForDrift = false;
|
||||
|
||||
/** Gates `Graph::pruneMissingTests()` — only safe on full `--fresh` rebuilds. */
|
||||
private bool $freshRebuild = false;
|
||||
|
||||
private bool $filteredMode = false;
|
||||
@ -159,15 +154,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicts whether TIA will activate for this run, *before* the Tia
|
||||
* plugin's `handleArguments` runs. Mirrors the same gate the plugin
|
||||
* itself applies: `--tia` on the CLI, or `pest()->tia()->always()`
|
||||
* (optionally `->locally()`, which is honoured only outside CI).
|
||||
*
|
||||
* Used by the restarters in `bin/pest`, which fire after
|
||||
* `Kernel::boot()` (so `tests/Pest.php` has populated WatchPatterns)
|
||||
* but before any plugin's `handleArguments` runs.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public static function isEnabledForRun(array $arguments): bool
|
||||
@ -183,10 +169,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return false;
|
||||
}
|
||||
|
||||
// `locally()` opts out on CI. Environment::name() reflects --ci
|
||||
// only after Environment's own handleArguments has run, which
|
||||
// hasn't happened at the restart-decision point — so check argv
|
||||
// directly here.
|
||||
return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true));
|
||||
}
|
||||
|
||||
@ -258,8 +240,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
|
||||
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
|
||||
|
||||
// Always strip TIA-owned flags so they never reach PHPUnit, even when
|
||||
// TIA is not active for this run.
|
||||
$arguments = $this->popArgument(self::OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
|
||||
@ -412,8 +392,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||
// Snapshot any currently-dirty files so the first replay run
|
||||
// doesn't mis-report them as changed. See the series record path.
|
||||
$graph->setLastRunTree(
|
||||
$this->branch,
|
||||
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
||||
@ -523,10 +501,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural drift → discard graph, return null (caller enters record mode).
|
||||
* Environmental drift → drop results, keep edges, return updated graph.
|
||||
* Match → return graph unchanged.
|
||||
*
|
||||
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
|
||||
*/
|
||||
private function reconcileFingerprint(Graph $graph, array $current): ?Graph
|
||||
@ -594,14 +568,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
$fingerprint = Fingerprint::compute($projectRoot);
|
||||
|
||||
// `--fresh` is meant to be a clean slate: nuke the entire per-project
|
||||
// state dir up front (graph, baseline, worker partials, fingerprint,
|
||||
// JS module cache, coverage marker, etc.). Wiping per-key in code
|
||||
// would leave room for stale entries we forgot about — most
|
||||
// recently, status-7/8 result entries with no `file` that survived
|
||||
// a rebuild and kept tripping `hasUnlocatedFailuresOrErrors()` on
|
||||
// subsequent `--filtered` runs. Safe here because `handleParent`
|
||||
// runs in the parent before any worker is spawned.
|
||||
if ($forceRebuild) {
|
||||
Storage::purge($projectRoot);
|
||||
}
|
||||
@ -624,10 +590,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
}
|
||||
|
||||
// No local graph and not being forced to rebuild from scratch: try
|
||||
// to pull a team-shared baseline so fresh checkouts (new devs, CI
|
||||
// containers) don't pay the full record cost. If the pull succeeds
|
||||
// the graph is re-read and reconciled against the local env.
|
||||
if (! $graph instanceof Graph
|
||||
&& ! $forceRebuild
|
||||
&& ! $this->baselineFetchAttemptedForDrift
|
||||
@ -643,17 +605,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->state->write(self::KEY_COVERAGE_MARKER, '');
|
||||
}
|
||||
|
||||
// Kick off the JS module graph resolver in the background so it
|
||||
// runs in parallel with the test suite. By the time the flush
|
||||
// path calls `JsModuleGraph::build()`, the result is usually
|
||||
// already on stdout and `wait()` returns instantly. Cheap when
|
||||
// the cache is fresh — the warmer fingerprint-checks first and
|
||||
// skips spawning Node entirely.
|
||||
if (! Parallel::isWorker() && JsModuleGraph::isApplicable($projectRoot)) {
|
||||
JsModuleGraph::warmInBackground($projectRoot);
|
||||
}
|
||||
|
||||
// First `--tia --coverage` run: no cache to merge against yet, must record the full suite.
|
||||
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
|
||||
return $this->enterRecordMode($arguments);
|
||||
}
|
||||
@ -749,12 +704,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
/**
|
||||
* During replay, affected tests execute normally. If a coverage driver is
|
||||
* available, record those executions too so refactors that introduce new
|
||||
* dependencies update the graph without requiring a full `--fresh` run.
|
||||
* Cached tests short-circuit before `Recorder::beginTest()`, so they don't
|
||||
* produce empty replacement edges.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
@ -820,12 +769,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$failedFromCache = [];
|
||||
|
||||
if ($this->filteredMode) {
|
||||
// `failedOrErroredTestFiles()` only yields failures that have a
|
||||
// mapped file — the snapshot path now reflects on the class
|
||||
// when the collector loses the path, so an unlocated failure
|
||||
// is no longer expected. If one slips through, doing the best
|
||||
// we can with the located ones is strictly better than bailing
|
||||
// to a full suite.
|
||||
$failedFromCache = $graph->failedOrErroredTestFiles($this->branch);
|
||||
}
|
||||
|
||||
@ -886,17 +829,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces what TIA decided to run and why, before the suite
|
||||
* starts. Two pieces a developer wants at a glance:
|
||||
*
|
||||
* 1. *How many* tests are about to run — the deciding factor for
|
||||
* whether they wait for the run or kick off something else.
|
||||
* 2. *Why* — which changed files drove the affected set, and how
|
||||
* many came in via cached failures (filtered mode).
|
||||
*
|
||||
* Stays quiet when nothing is affected: the existing
|
||||
* `NoAffectedTestsFound` panic / recap line covers that path.
|
||||
*
|
||||
* @param array<int, string> $changedFiles
|
||||
* @param array<int, string> $affectedFromChanges
|
||||
* @param array<int, string> $failedFromCache
|
||||
@ -908,9 +840,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
// Failures that overlap with the change-driven set are already
|
||||
// pulled in by edges — don't double-count them as a separate
|
||||
// reason in the breakdown.
|
||||
$newFailures = $failedFromCache === []
|
||||
? 0
|
||||
: count(array_diff($failedFromCache, $affectedFromChanges));
|
||||
@ -954,9 +883,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
|
||||
));
|
||||
|
||||
// List the first few affected test files so the developer can see
|
||||
// *which* tests are about to run, not just the count. Capped at 10
|
||||
// to keep the line tight on large impact sets.
|
||||
$previewLimit = 10;
|
||||
$sorted = $affected;
|
||||
sort($sorted);
|
||||
@ -1273,8 +1199,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private function registerRecap(): void
|
||||
{
|
||||
DefaultPrinter::addRecap(function (): string {
|
||||
// mergeWorkerReplayPartials fires before addOutput on --parallel, which is intentional:
|
||||
// partial keys are deleted on read so the later addOutput call becomes a no-op.
|
||||
if (Parallel::isEnabled() && ! Parallel::isWorker()) {
|
||||
$this->mergeWorkerReplayPartials();
|
||||
}
|
||||
@ -1364,13 +1288,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
foreach ($results as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
// The collector occasionally hands us nothing usable: PHPUnit's
|
||||
// Prepared event can miss the file for Pest-generated classes,
|
||||
// and an eval'd class path (".../IndexTest.php(1) : eval()'d code")
|
||||
// would be rejected later by Graph::relative(). Recover the real
|
||||
// path from the class embedded in the test ID — without it,
|
||||
// filtered runs lose the ability to re-run only the failing test
|
||||
// next time.
|
||||
if ($file === null || str_contains($file, "eval()'d")) {
|
||||
$file = $this->resolveFailedTestFile($testId);
|
||||
}
|
||||
@ -1390,22 +1307,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the source file for a Pest-generated test class.
|
||||
*
|
||||
* Pest synthesises a per-test class via `eval()` and writes the
|
||||
* original test file path to a `private static $__filename` property
|
||||
* (see `src/Factories/TestCaseFactory.php`). Reflecting on the class
|
||||
* with `getFileName()` would return the eval'd location, which
|
||||
* `Graph::relative()` rejects — losing the file mapping.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Read the `__filename` static if the class declares it (Pest
|
||||
* tests).
|
||||
* 2. Otherwise use `getFileName()` and skip eval'd frames by
|
||||
* walking up the parent class chain — a plain PHPUnit test
|
||||
* lives in a real file at the top of that chain.
|
||||
*/
|
||||
private function resolveFailedTestFile(string $testId): ?string
|
||||
{
|
||||
$class = strstr($testId, '::', true);
|
||||
@ -1456,11 +1357,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP source changes can introduce new dependencies. Without a coverage
|
||||
* driver, replay can run the currently affected tests but cannot refresh
|
||||
* the graph, so a later edit to the newly introduced dependency could be
|
||||
* missed. Treat those runs as full-suite unless coverage can self-heal.
|
||||
*
|
||||
* @param array<int, string> $changedFiles
|
||||
*/
|
||||
private function hasProjectPhpSourceChanges(array $changedFiles): bool
|
||||
|
||||
@ -26,20 +26,10 @@ final readonly class BaselineSync
|
||||
|
||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||
|
||||
// Subdirectory under the per-project state dir (`~/.pest/tia/<project>/`)
|
||||
// where artifacts from previous downloads are kept (one subfolder per
|
||||
// workflow run id). Hitting the same run id on a later fetch skips
|
||||
// the `gh run download` round trip entirely — artifacts are immutable
|
||||
// per run id, so the cached bytes are exactly what gh would re-download.
|
||||
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
|
||||
|
||||
// Most recently downloaded artifacts to retain on disk. Branch
|
||||
// switches and partial baseline rollouts hop across run ids — keeping
|
||||
// the last few avoids re-downloading when the user toggles between
|
||||
// them. Older entries get evicted on the next download.
|
||||
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
|
||||
|
||||
// 24 h cooldown after a failed fetch so repeated `pest --tia` calls don't re-hit `gh run list`.
|
||||
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
||||
|
||||
public function __construct(
|
||||
@ -78,11 +68,6 @@ final readonly class BaselineSync
|
||||
$payload = $this->download($repo, $projectRoot, $failureKind);
|
||||
|
||||
if ($payload === null) {
|
||||
// Genuine "no baseline published yet" → cool down and show
|
||||
// the publish-instructions YAML so the user can wire CI.
|
||||
// Anything else (missing gh, auth, network, mid-download
|
||||
// error) is transient and gets a one-line diagnostic
|
||||
// instead — no cooldown, no noisy YAML.
|
||||
if ($failureKind === 'no-runs' || $failureKind === null) {
|
||||
$this->startCooldown();
|
||||
$this->emitPublishInstructions($repo);
|
||||
@ -169,8 +154,6 @@ final readonly class BaselineSync
|
||||
$this->renderDetail('To share the baseline with your team, add this workflow to the repo:');
|
||||
$this->renderDetail('.github/workflows/tia-baseline.yml');
|
||||
|
||||
// YAML stays as a raw indented block — Termwind would mangle the
|
||||
// verbatim whitespace.
|
||||
$indentedYaml = array_map(
|
||||
static fn (string $line): string => ' '.$line,
|
||||
explode("\n", $yaml),
|
||||
@ -182,7 +165,6 @@ final readonly class BaselineSync
|
||||
$this->renderDetail('Details: https://pestphp.com/docs/tia/ci');
|
||||
}
|
||||
|
||||
// `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var.
|
||||
private function isCi(): bool
|
||||
{
|
||||
return getenv('GITHUB_ACTIONS') === 'true'
|
||||
@ -326,9 +308,6 @@ YAML;
|
||||
if ($listError !== null) {
|
||||
$failureKind = $listError['kind'];
|
||||
|
||||
// Tier 1 — actionable misconfiguration. Stop the suite and
|
||||
// tell the user what to fix; a silent fall-through to a
|
||||
// full record would just paper over the bug.
|
||||
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
sprintf('Failed to query baseline runs — %s', $listError['message']),
|
||||
@ -336,8 +315,6 @@ YAML;
|
||||
));
|
||||
}
|
||||
|
||||
// Tier 2 — transient (network, rate-limit, unknown). Surface
|
||||
// the diagnostic but let the suite fall through to record mode.
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Failed to query baseline runs — %s',
|
||||
$listError['message'],
|
||||
@ -347,7 +324,6 @@ YAML;
|
||||
}
|
||||
|
||||
if ($runId === null) {
|
||||
// Genuine missing baseline — caller emits publish instructions.
|
||||
$failureKind = 'no-runs';
|
||||
|
||||
return null;
|
||||
@ -355,12 +331,7 @@ YAML;
|
||||
|
||||
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
||||
|
||||
// Cache hit: a previous fetch already extracted this run id's
|
||||
// artifact into the run-specific dir. Read the assets straight
|
||||
// out of it and skip `gh run download` entirely.
|
||||
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
|
||||
// Bump the dir mtime so trimDownloadCache() treats this run
|
||||
// id as recently used and doesn't evict it later.
|
||||
@touch($runCacheDir);
|
||||
|
||||
$this->renderBadge('INFO', sprintf(
|
||||
@ -414,7 +385,6 @@ YAML;
|
||||
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
||||
$failureKind = $diagnosis['kind'];
|
||||
|
||||
// Tier 1 — actionable. Stop hard with a clear diagnostic.
|
||||
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
sprintf('Baseline download failed — %s', $diagnosis['message']),
|
||||
@ -422,7 +392,6 @@ YAML;
|
||||
));
|
||||
}
|
||||
|
||||
// Tier 2 — transient. Diagnostic + fall through to record mode.
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Baseline download failed — %s',
|
||||
$diagnosis['message'],
|
||||
@ -436,9 +405,6 @@ YAML;
|
||||
if ($payload === null) {
|
||||
$this->cleanup($runCacheDir);
|
||||
|
||||
// Artifact present but malformed — CI's publish step is
|
||||
// broken. Falling through would silently waste the next
|
||||
// run; surface the bug instead.
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
'Baseline downloaded but the artifact is missing expected files (graph.json).',
|
||||
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
|
||||
@ -450,11 +416,6 @@ YAML;
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the artifact's compressed size so the progress bar has a
|
||||
* denominator. Returns null on any failure — callers fall back to a
|
||||
* size-less spinner.
|
||||
*/
|
||||
private function artifactSize(string $repo, string $runId): ?int
|
||||
{
|
||||
$process = new Process([
|
||||
@ -484,11 +445,6 @@ YAML;
|
||||
$speed = (int) ($current / $elapsed);
|
||||
|
||||
if ($totalBytes !== null && $totalBytes > 0) {
|
||||
// gh extracts as it downloads, so disk size can briefly exceed
|
||||
// the compressed `size_in_bytes` for multi-file artifacts. Cap
|
||||
// the percentage at 99% until the process actually exits — the
|
||||
// cleared line + completion message take care of the final
|
||||
// "100%" message naturally.
|
||||
$percent = min(99, (int) floor(($current / $totalBytes) * 100));
|
||||
$message = sprintf(
|
||||
' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)',
|
||||
@ -505,8 +461,6 @@ YAML;
|
||||
);
|
||||
}
|
||||
|
||||
// \r returns to start of line, \033[K erases from cursor to end —
|
||||
// safe regardless of message length, no ANSI-aware padding needed.
|
||||
$this->output->write("\r\033[K".$message);
|
||||
}
|
||||
|
||||
@ -565,11 +519,6 @@ YAML;
|
||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ids returned by `gh` are numeric strings, but defend against a
|
||||
* surprising response by stripping anything non-alphanumeric — the
|
||||
* value is used as a directory name.
|
||||
*/
|
||||
private function safeRunId(string $runId): string
|
||||
{
|
||||
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
|
||||
@ -577,13 +526,6 @@ YAML;
|
||||
return $sanitised === '' ? 'unknown' : $sanitised;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the N most recently used cached artifacts and evict the rest.
|
||||
* Recency is taken from the directory mtime — `mkdir`/`gh run download`
|
||||
* stamps it on a fresh entry, and a cache hit `touch`es it back to
|
||||
* the front of the line, so a frequently-reused run id won't be
|
||||
* evicted just because newer ids have been seen between uses.
|
||||
*/
|
||||
private function trimDownloadCache(string $projectRoot): void
|
||||
{
|
||||
$root = $this->downloadCacheDir($projectRoot);
|
||||
@ -632,12 +574,6 @@ YAML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `[runId|null, errorOrNull]`. Distinguishes "no runs yet"
|
||||
* (runId null, error null) from "couldn't ask GitHub" (error
|
||||
* populated with kind + message). Lets the caller pick between
|
||||
* showing publish instructions and emitting a transient-failure
|
||||
* diagnostic.
|
||||
*
|
||||
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
|
||||
*/
|
||||
private function latestSuccessfulRunIdWithError(string $repo): array
|
||||
@ -673,10 +609,6 @@ YAML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a chunk of `gh` stderr/stdout to a coarse kind + a short,
|
||||
* actionable message. Falls back to the first non-empty line of
|
||||
* the output so even unrecognised errors aren't reduced to "unknown".
|
||||
*
|
||||
* @return array{kind: string, message: string}
|
||||
*/
|
||||
private function classifyGhError(string $output): array
|
||||
@ -722,8 +654,6 @@ YAML;
|
||||
];
|
||||
}
|
||||
|
||||
// Unknown — surface the first informative line so the user has
|
||||
// *something* to act on.
|
||||
$message = trim(strtok($output, "\n"));
|
||||
|
||||
return ['kind' => 'unknown', 'message' => $message];
|
||||
|
||||
@ -22,11 +22,7 @@ final readonly class Bootstrapper implements BootstrapperContract
|
||||
}
|
||||
|
||||
/**
|
||||
* TIA's per-project state directory. Default layout is
|
||||
* `~/.pest/tia/<project-key>/` so the graph survives `composer
|
||||
* install`, stays out of the project tree, and is naturally shared
|
||||
* across worktrees of the same repo. See {@see Storage} for the key
|
||||
* derivation and the home-dir-missing fallback.
|
||||
*/
|
||||
private function tempDir(): string
|
||||
{
|
||||
|
||||
@ -24,7 +24,6 @@ final readonly class ChangedFiles
|
||||
return $files;
|
||||
}
|
||||
|
||||
// Union with last-run snapshot: catches reverts that git reports clean but are new vs the snapshot.
|
||||
$candidates = array_fill_keys($files, true);
|
||||
|
||||
foreach (array_keys($lastRunTree) as $snapshotted) {
|
||||
@ -45,8 +44,6 @@ final readonly class ChangedFiles
|
||||
}
|
||||
|
||||
if (! $exists) {
|
||||
// Always invalidate deletions — a stale cached result from before the deletion
|
||||
// would persist forever otherwise, even if the snapshot recorded the empty sentinel.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
@ -71,10 +68,6 @@ final readonly class ChangedFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes content hashes for the given project-relative files. Used to
|
||||
* snapshot the working tree after a successful run so the next run can
|
||||
* detect which files are actually different.
|
||||
*
|
||||
* @param array<int, string> $files
|
||||
* @return array<string, string> path → xxh128 content hash
|
||||
*/
|
||||
@ -86,9 +79,6 @@ final readonly class ChangedFiles
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// Record the deletion with an empty-string sentinel so the
|
||||
// next run recognises "still deleted" as unchanged rather
|
||||
// than re-flagging the file as a fresh change.
|
||||
$out[$file] = '';
|
||||
|
||||
continue;
|
||||
@ -106,8 +96,6 @@ final readonly class ChangedFiles
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||
* the recorded SHA is no longer reachable
|
||||
* from HEAD (rebase / force-push).
|
||||
*/
|
||||
public function since(?string $sha): ?array
|
||||
{
|
||||
@ -127,9 +115,6 @@ final readonly class ChangedFiles
|
||||
|
||||
$files = array_merge($files, $this->workingTreeChanges());
|
||||
|
||||
// Normalise + dedupe, filtering out paths that can never belong to the
|
||||
// graph: vendor (caught by the fingerprint instead), cache dirs, and
|
||||
// anything starting with a dot we don't care about.
|
||||
$unique = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
@ -144,13 +129,6 @@ final readonly class ChangedFiles
|
||||
|
||||
$candidates = array_keys($unique);
|
||||
|
||||
// Behavioural de-noising: for every file git calls "changed", hash
|
||||
// the current content and the content at `$sha` through
|
||||
// `ContentHash::of()`. A change that only touched comments /
|
||||
// whitespace / blade `{{-- --}}` blocks produces the same hash on
|
||||
// both sides and gets dropped before it can invalidate any test.
|
||||
// Without this, a single-comment edit on a migration re-runs the
|
||||
// entire DB-touching suite.
|
||||
if ($sha !== null && $sha !== '') {
|
||||
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
||||
}
|
||||
@ -170,7 +148,6 @@ final readonly class ChangedFiles
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// Deleted on disk — a genuine change, keep it.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
@ -187,8 +164,6 @@ final readonly class ChangedFiles
|
||||
$baselineContent = $this->contentAtSha($sha, $file);
|
||||
|
||||
if ($baselineContent === null) {
|
||||
// Couldn't read the baseline (new file, binary, `git show`
|
||||
// failed). Err on the side of re-running.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
@ -204,12 +179,6 @@ final readonly class ChangedFiles
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `$path` at `$sha` via `git show`. Returns null when the file
|
||||
* didn't exist at that SHA, when git errors, or when the content
|
||||
* isn't valid UTF-8-safe bytes (rare — binary files that happen to
|
||||
* be tracked).
|
||||
*/
|
||||
private function contentAtSha(string $sha, string $path): ?string
|
||||
{
|
||||
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
|
||||
@ -231,10 +200,6 @@ final readonly class ChangedFiles
|
||||
'.phpunit.result.cache',
|
||||
'vendor/',
|
||||
'node_modules/',
|
||||
// Laravel regenerates these from manifest state
|
||||
// (package.json, service providers) at boot — they're
|
||||
// fully derived, not authored. Treating them as
|
||||
// "changes" just flaps the diff noisily.
|
||||
'bootstrap/cache/',
|
||||
];
|
||||
|
||||
@ -281,9 +246,6 @@ final readonly class ChangedFiles
|
||||
);
|
||||
$process->run();
|
||||
|
||||
// Exit 0 → ancestor; 1 → not ancestor; anything else → git error
|
||||
// (e.g. unknown commit after a rebase/gc). Treat non-zero as
|
||||
// "unreachable" and force a rebuild.
|
||||
return $process->getExitCode() === 0;
|
||||
}
|
||||
|
||||
@ -310,14 +272,6 @@ final readonly class ChangedFiles
|
||||
*/
|
||||
private function workingTreeChanges(): array
|
||||
{
|
||||
// `-z` produces NUL-terminated records with no path quoting, so paths
|
||||
// that contain spaces, tabs, unicode or other special characters
|
||||
// are passed through verbatim. Without `-z`, git wraps such paths in
|
||||
// quotes with backslash escapes, which would corrupt our lookup keys.
|
||||
//
|
||||
// Record format: `XY <SP> <path> <NUL>` for most entries, and
|
||||
// `R <new> <NUL> <orig> <NUL>` for renames/copies (two NUL-separated
|
||||
// fields).
|
||||
$process = new Process(
|
||||
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
|
||||
$this->projectRoot,
|
||||
@ -348,8 +302,6 @@ final readonly class ChangedFiles
|
||||
$status = substr($record, 0, 2);
|
||||
$path = substr($record, 3);
|
||||
|
||||
// Renames/copies emit two records: the new path first, then the
|
||||
// original. Consume both.
|
||||
if ($status[0] === 'R' || $status[0] === 'C') {
|
||||
$files[] = $path;
|
||||
|
||||
|
||||
@ -12,8 +12,6 @@ use Pest\Support\Container;
|
||||
final class Configuration
|
||||
{
|
||||
/**
|
||||
* Activates TIA for every run without requiring the `--tia` CLI flag.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function always(): self
|
||||
@ -26,10 +24,6 @@ final class Configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts the `always()` activation to local environments only.
|
||||
* On CI (`--ci` flag or `CI` env var), TIA is skipped even if `always()` is set.
|
||||
* Explicit `--tia` on the CLI always takes effect regardless.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function locally(): self
|
||||
@ -43,10 +37,6 @@ final class Configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* In replay mode, instead of short-circuiting cached results for unaffected
|
||||
* tests, narrows PHPUnit to only the affected files — unaffected tests are
|
||||
* never loaded. Can also be enabled with the `--filtered` CLI flag.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function filtered(): self
|
||||
@ -59,9 +49,6 @@ final class Configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds watch-pattern → test-directory mappings that supplement (or
|
||||
* override) the built-in defaults.
|
||||
*
|
||||
* @param array<string, string> $patterns glob → project-relative test dir
|
||||
* @return $this
|
||||
*/
|
||||
|
||||
@ -9,11 +9,6 @@ namespace Pest\Plugins\Tia;
|
||||
*/
|
||||
final class ContentHash
|
||||
{
|
||||
/**
|
||||
* xxh128 hex of the file's "behavioural" shape, or `false` when the
|
||||
* file can't be read. Callers should treat `false` the same way they
|
||||
* treated a failed `hash_file()` previously.
|
||||
*/
|
||||
public static function of(string $absolute): string|false
|
||||
{
|
||||
$raw = @file_get_contents($absolute);
|
||||
@ -25,11 +20,6 @@ final class ContentHash
|
||||
return self::ofContent($absolute, $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `of()` but accepts the file contents in memory. Used when
|
||||
* we already have the bytes (e.g. from `git show <sha>:<path>`) and
|
||||
* want to avoid a disk round-trip.
|
||||
*/
|
||||
public static function ofContent(string $path, string $raw): string
|
||||
{
|
||||
$lower = strtolower($path);
|
||||
@ -51,13 +41,6 @@ final class ContentHash
|
||||
return hash('xxh128', $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenise the content and hash the concatenated values of every
|
||||
* token except whitespace / comment / docblock. `token_get_all()`
|
||||
* is built-in, fast, and enough to collapse any formatting-only
|
||||
* edit. If tokenisation fails (rare syntax error), fall back to
|
||||
* the raw hash so the caller still gets a deterministic signal.
|
||||
*/
|
||||
private static function hashPhpContent(string $raw): string
|
||||
{
|
||||
$tokens = @token_get_all($raw);
|
||||
@ -88,14 +71,6 @@ final class ContentHash
|
||||
return hash('xxh128', $normalised);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blade templates aren't PHP syntactically, so `token_get_all()`
|
||||
* doesn't help. Strip `{{-- … --}}` comments (the only Blade-native
|
||||
* comment form) and collapse whitespace runs. Output differences
|
||||
* that would survive the Blade compiler (markup reordering, new
|
||||
* directives, changed interpolation) still flip the hash; pure
|
||||
* reformatting does not.
|
||||
*/
|
||||
private static function hashBladeContent(string $raw): string
|
||||
{
|
||||
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
|
||||
@ -104,17 +79,6 @@ final class ContentHash
|
||||
return hash('xxh128', trim($stripped));
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative JS/TS/Vue/Svelte normaliser. Strips `//` line
|
||||
* comments and `/* … *\/` block comments that appear on their own
|
||||
* lines (including leading indentation), then collapses
|
||||
* whitespace. Deliberately leaves trailing comments after code
|
||||
* alone — a string literal like `'http://foo'` would be unsafe to
|
||||
* split on `//` without a full lexer. The direction of error is
|
||||
* over-detection (we may not strip a trailing comment that's
|
||||
* purely cosmetic), never under-detection. Blank lines and
|
||||
* indentation changes are erased regardless.
|
||||
*/
|
||||
private static function hashJsContent(string $raw): string
|
||||
{
|
||||
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;
|
||||
|
||||
@ -9,33 +9,15 @@ namespace Pest\Plugins\Tia\Contracts;
|
||||
*/
|
||||
interface State
|
||||
{
|
||||
/**
|
||||
* Returns the stored blob for `$key`, or `null` when the key is unset
|
||||
* or cannot be read.
|
||||
*/
|
||||
public function read(string $key): ?string;
|
||||
|
||||
/**
|
||||
* Atomically stores `$content` under `$key`. Existing value (if any) is
|
||||
* replaced. Implementations SHOULD guarantee that concurrent readers
|
||||
* never observe partial writes.
|
||||
*/
|
||||
public function write(string $key, string $content): bool;
|
||||
|
||||
/**
|
||||
* Removes `$key`. Returns true whether or not the key existed beforehand
|
||||
* — callers should treat a `true` result as "the key is now absent",
|
||||
* not "the key was present and has been removed."
|
||||
*/
|
||||
public function delete(string $key): bool;
|
||||
|
||||
public function exists(string $key): bool;
|
||||
|
||||
/**
|
||||
* Returns every key whose name starts with `$prefix`. Used to collect
|
||||
* paratest worker partials (`worker-edges-<token>.json`, etc.) without
|
||||
* exposing backend-specific glob semantics.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function keysWithPrefix(string $prefix): array;
|
||||
|
||||
@ -14,19 +14,11 @@ use Throwable;
|
||||
final class CoverageCollector
|
||||
{
|
||||
/**
|
||||
* Cached `className → test file` lookups. Class reflection is cheap
|
||||
* individually but the record run can visit tens of thousands of
|
||||
* samples, so the cache matters.
|
||||
*
|
||||
* @var array<string, string|null>
|
||||
*/
|
||||
private array $classFileCache = [];
|
||||
|
||||
/**
|
||||
* Rebuilds the same `absolute test file → list<absolute source file>`
|
||||
* shape that `Recorder::perTestFiles()` exposes, so callers can treat
|
||||
* the two collectors interchangeably when feeding the graph.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function perTestFiles(): array
|
||||
@ -48,9 +40,6 @@ final class CoverageCollector
|
||||
$edges = [];
|
||||
|
||||
foreach ($lineCoverage as $sourceFile => $lines) {
|
||||
// Collect the set of tests that hit any line in this file once,
|
||||
// then emit one edge per (testFile, sourceFile) pair. Walking
|
||||
// the lines per test would re-resolve the test file repeatedly.
|
||||
$testIds = [];
|
||||
|
||||
foreach ($lines as $hits) {
|
||||
@ -90,9 +79,6 @@ final class CoverageCollector
|
||||
|
||||
private function testIdToFile(string $testId): ?string
|
||||
{
|
||||
// PHPUnit's test id is `ClassName::methodName` with an optional
|
||||
// `#dataSetName` suffix for data-provider runs. Strip the dataset
|
||||
// part — we only need the class.
|
||||
$hash = strpos($testId, '#');
|
||||
$identifier = $hash === false ? $testId : substr($testId, 0, $hash);
|
||||
|
||||
@ -120,9 +106,6 @@ final class CoverageCollector
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
|
||||
// Pest's eval'd test classes expose the original `.php` path on a
|
||||
// static `$__filename`. The eval'd class itself has no file of its
|
||||
// own, so prefer this property when present.
|
||||
if ($reflection->hasProperty('__filename')) {
|
||||
$property = $reflection->getProperty('__filename');
|
||||
|
||||
|
||||
@ -28,9 +28,6 @@ final class CoverageMerger
|
||||
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
if ($cachedBytes === null) {
|
||||
// First `--tia --coverage` run: nothing cached yet, so the
|
||||
// current file already represents the full suite. Capture it
|
||||
// verbatim (as serialised bytes) for next time.
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if ($current instanceof CodeCoverage) {
|
||||
@ -61,8 +58,6 @@ final class CoverageMerger
|
||||
|
||||
$serialised = serialize($cached);
|
||||
|
||||
// Write back to the PHPUnit-style `.cov` path so the report reader
|
||||
// can `require` it, and to the state cache for the next run.
|
||||
@file_put_contents(
|
||||
$reportPath,
|
||||
'<?php return unserialize('.var_export($serialised, true).");\n",
|
||||
@ -84,12 +79,6 @@ final class CoverageMerger
|
||||
return $decoded === false ? null : $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes from `$cached`'s per-line test attribution any test id that
|
||||
* appears in `$current`. Those tests just ran, so the fresh slice is
|
||||
* authoritative — keeping stale attribution in the cache would claim
|
||||
* a test still covers a line it no longer touches.
|
||||
*/
|
||||
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
|
||||
{
|
||||
$currentIds = self::collectTestIds($current);
|
||||
|
||||
@ -13,13 +13,6 @@ final class BladeEdges
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
/**
|
||||
* App-scoped marker that makes `arm()` idempotent. Tests call it
|
||||
* from every `setUp()`, and Laravel reuses the same app instance
|
||||
* across tests in most configurations — without this guard we'd
|
||||
* stack one composer per test and replay every one of them on
|
||||
* every view render.
|
||||
*/
|
||||
private const string MARKER = 'pest.tia.blade-edges-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
|
||||
@ -13,22 +13,8 @@ final class InertiaEdges
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
/**
|
||||
* Event class name used as the listener key. Stored *without* a
|
||||
* leading backslash because Laravel's `Dispatcher` keys
|
||||
* `$listeners[$eventName]` by the literal string passed to
|
||||
* `listen()`, and looks up incoming events by their PHP-class
|
||||
* name (`get_class($event)`), which never has a leading
|
||||
* backslash. A `\Illuminate\…` key would silently never match.
|
||||
*/
|
||||
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
|
||||
|
||||
/**
|
||||
* App-scoped marker that makes `arm()` idempotent across per-test
|
||||
* `setUp()` calls. Laravel reuses the same app across tests in
|
||||
* most configurations — without this guard we'd stack one
|
||||
* listener per test.
|
||||
*/
|
||||
private const string MARKER = 'pest.tia.inertia-edges-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
@ -87,16 +73,8 @@ final class InertiaEdges
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the Inertia component name out of a Laravel response,
|
||||
* handling both XHR (`X-Inertia` + JSON body) and full HTML
|
||||
* (`<div id="app" data-page="…">`) shapes. Returns null for any
|
||||
* non-Inertia response so the caller can ignore it cheaply.
|
||||
*/
|
||||
private static function extractComponent(object $response): ?string
|
||||
{
|
||||
// XHR path: Inertia sets an `X-Inertia: true` header and the
|
||||
// body is JSON with a `component` key.
|
||||
if (property_exists($response, 'headers') && is_object($response->headers)) {
|
||||
$headers = $response->headers;
|
||||
|
||||
@ -117,32 +95,12 @@ final class InertiaEdges
|
||||
}
|
||||
}
|
||||
|
||||
// Initial-load HTML path. Inertia ships two shapes here and
|
||||
// we honour both:
|
||||
//
|
||||
// 1. SSR-safe script tag — `<script data-page="app"
|
||||
// type="application/json">{…JSON…}</script>`. The
|
||||
// Laravel React starter kit (and modern Inertia-React)
|
||||
// use this so the JSON survives server-rendered
|
||||
// hydration without HTML-encoding the payload into an
|
||||
// attribute. The `data-page="app"` *attribute value* is
|
||||
// the literal string `"app"` — only the tag *body*
|
||||
// carries the page JSON.
|
||||
// 2. Classic — `<div id="app" data-page="{…JSON…}">…`. Older
|
||||
// Inertia-Vue and Inertia-React still emit this. Here
|
||||
// `data-page` IS the JSON, HTML-entity-encoded.
|
||||
//
|
||||
// Try the script-tag shape first; if the response uses it,
|
||||
// the classic regex would also see a `data-page="app"` token
|
||||
// and try to JSON-decode the literal string `"app"`.
|
||||
$content = self::readContent($response);
|
||||
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lookahead pair handles arbitrary attribute order on the
|
||||
// `<script>` tag.
|
||||
if (str_contains($content, 'type="application/json"')
|
||||
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
|
||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
||||
@ -152,9 +110,6 @@ final class InertiaEdges
|
||||
}
|
||||
}
|
||||
|
||||
// Classic: only accept a value that looks like a JSON object
|
||||
// (`{…}`). Avoids matching the script-tag form's
|
||||
// `data-page="app"` attribute when both shapes coexist.
|
||||
if (str_contains($content, 'data-page=')
|
||||
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
|
||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
||||
@ -167,12 +122,6 @@ final class InertiaEdges
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an Inertia page JSON blob and returns the `component`
|
||||
* field if it's a non-empty string. Used by both the script-tag
|
||||
* and the `data-page`-attribute paths so the success criteria are
|
||||
* identical.
|
||||
*/
|
||||
private static function componentFromJson(string $json): ?string
|
||||
{
|
||||
/** @var mixed $decoded */
|
||||
|
||||
@ -11,11 +11,6 @@ use Pest\Plugins\Tia\Contracts\State;
|
||||
*/
|
||||
final readonly class FileState implements State
|
||||
{
|
||||
/**
|
||||
* Configured root. May not exist on disk yet; resolved + created on
|
||||
* the first write. Keeping the raw string lets the instance be built
|
||||
* before Pest's temp dir has been materialised.
|
||||
*/
|
||||
private string $rootDir;
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
@ -49,8 +44,6 @@ final readonly class FileState implements State
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic rename — on POSIX filesystems this is a single-step
|
||||
// replacement, so concurrent readers never see a half-written file.
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
@ -100,22 +93,11 @@ final readonly class FileState implements State
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path for `$key`. Not part of the interface — used by the
|
||||
* coverage merger and similar callers that need direct filesystem
|
||||
* access (e.g. `require` on a cached PHP file). Consumers that only
|
||||
* deal in bytes should go through `read()` / `write()`.
|
||||
*/
|
||||
public function pathFor(string $key): string
|
||||
{
|
||||
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved root if it exists already, otherwise `null`.
|
||||
* Used by read-side helpers so they don't eagerly create the directory
|
||||
* just to find nothing inside.
|
||||
*/
|
||||
private function resolvedRoot(): ?string
|
||||
{
|
||||
$resolved = @realpath($this->rootDir);
|
||||
@ -123,10 +105,6 @@ final readonly class FileState implements State
|
||||
return $resolved === false ? null : $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the root dir on demand. Returns false only when creation
|
||||
* fails and the directory still isn't there afterwards.
|
||||
*/
|
||||
private function ensureRoot(): bool
|
||||
{
|
||||
if (is_dir($this->rootDir)) {
|
||||
|
||||
@ -22,7 +22,6 @@ final readonly class Fingerprint
|
||||
return [
|
||||
'structural' => [
|
||||
'schema' => self::SCHEMA_VERSION,
|
||||
// 'composer_lock' => self::composerLockHash($projectRoot),
|
||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
@ -34,10 +33,6 @@ final readonly class Fingerprint
|
||||
'composer_json' => self::composerJsonHash($projectRoot),
|
||||
],
|
||||
'environmental' => [
|
||||
// Minor only (8.4, not 8.4.19) — CI's patch rarely matches dev installs.
|
||||
// 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
|
||||
// 'extensions' => self::extensionsFingerprint($projectRoot),
|
||||
// 'env_files' => self::envFilesHash($projectRoot),
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -135,7 +130,6 @@ final readonly class Fingerprint
|
||||
return self::bucket($fingerprint, 'environmental');
|
||||
}
|
||||
|
||||
// Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild.
|
||||
/**
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
|
||||
@ -46,7 +46,6 @@ final class Graph
|
||||
*/
|
||||
private array $baselines = [];
|
||||
|
||||
// Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
|
||||
private readonly string $projectRoot;
|
||||
|
||||
/** @var array<string, true>|null */
|
||||
@ -95,9 +94,6 @@ final class Graph
|
||||
|
||||
$affectedSet = [];
|
||||
|
||||
// Migrations can't flow through coverage edges: `RefreshDatabase` gives every test an edge to
|
||||
// every migration, so any migration change would re-run the whole DB suite. Route them via
|
||||
// table-intersection instead; unparseable migrations fall through to the watch pattern.
|
||||
$migrationPaths = [];
|
||||
$nonMigrationPaths = [];
|
||||
|
||||
@ -142,8 +138,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Inertia page routing: map changed page files to component names and intersect with recorded
|
||||
// component edges. Pages with no captured edges fall through to the watch pattern.
|
||||
$globalFrontendRuntimeFiles = [];
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
@ -174,8 +168,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Shared JS files: resolve via the recorded Vite module graph to their dependent page components.
|
||||
// Files absent from the map fall through to the watch pattern.
|
||||
$sharedFilesResolved = [];
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
||||
@ -202,9 +194,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// New JS files absent from the record-time map: ask Vite (strict, no PHP fallback) which pages
|
||||
// import them. A negative answer suppresses the broad watch broadcast; Node is the only resolver
|
||||
// trustworthy enough to honour a negative (PHP parser can miss custom aliases).
|
||||
$newJsFiles = [];
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
||||
@ -229,8 +218,6 @@ final class Graph
|
||||
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||
|
||||
if ($freshMap === null) {
|
||||
// Vite resolver unavailable — falling back to watch pattern; surface a line so the user
|
||||
// knows precision was downgraded rather than leaving the slower replay unexplained.
|
||||
View::render('components.badge', [
|
||||
'type' => 'WARN',
|
||||
'content' => sprintf(
|
||||
@ -243,7 +230,6 @@ final class Graph
|
||||
$pages = $freshMap[$rel] ?? [];
|
||||
|
||||
if ($pages === []) {
|
||||
// Vite confirms no page imports this file — suppress the watch broadcast.
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
|
||||
continue;
|
||||
@ -280,8 +266,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage-edge lookup (PHP → PHP). Migrations already handled above; skipping here prevents
|
||||
// their always-on edges from re-running the whole DB suite.
|
||||
$changedIds = [];
|
||||
$unknownSourceDirs = [];
|
||||
$sourcePhpChanged = false;
|
||||
@ -301,7 +285,6 @@ final class Graph
|
||||
$absolute = $this->projectRoot.'/'.$rel;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// Deleted source file unknown to the graph — no edge ever pointed to it.
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -311,8 +294,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Arch tests inspect structure by namespace/path, never producing coverage edges for the files
|
||||
// they examine — so a new class can fail an arch expectation without any edge to it.
|
||||
if ($sourcePhpChanged) {
|
||||
foreach (array_keys($this->edges) as $testFile) {
|
||||
if ($this->isArchTestFile($testFile)) {
|
||||
@ -336,7 +317,6 @@ final class Graph
|
||||
}
|
||||
|
||||
// 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])) {
|
||||
@ -358,13 +338,10 @@ final class Graph
|
||||
|
||||
$staticallyHandledBlade[$rel] = true;
|
||||
} elseif ($this->isBladeComponentPath($rel)) {
|
||||
// Anonymous component with no static usages — treat as orphan rather than broadcasting.
|
||||
$staticallyHandledBlade[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch-pattern fallback: files with no precise edges. Already-resolved files are excluded
|
||||
// to avoid re-broadcasting via the watch pattern and defeating the surgical match.
|
||||
$unknownToGraph = $unparseableMigrations;
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($preciselyHandledPages[$rel])) {
|
||||
@ -378,7 +355,6 @@ final class Graph
|
||||
}
|
||||
if (! isset($this->fileIds[$rel])) {
|
||||
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||
// Deleted file unknown to the graph — no edge ever pointed to it.
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -396,9 +372,6 @@ final class Graph
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
|
||||
// Sibling heuristic: unknown PHP source files may be new files whose graph was inherited from
|
||||
// another branch. Run tests that cover neighbouring files in the same directory so framework-
|
||||
// discovered files (Listeners, Events, Policies, etc.) aren't silently missed.
|
||||
if ($unknownSourceDirs !== []) {
|
||||
foreach ($this->edges as $testFile => $ids) {
|
||||
if (isset($affectedSet[$testFile])) {
|
||||
@ -509,9 +482,6 @@ final class Graph
|
||||
|
||||
$r = $baseline['results'][$testId];
|
||||
|
||||
// PHPUnit's `TestStatus::from(int)` ignores messages, so reconstruct
|
||||
// each variant via its specific factory. Keeps the stored message
|
||||
// intact (important for skips/failures shown to the user).
|
||||
return match ($r['status']) {
|
||||
0 => TestStatus::success(),
|
||||
1 => TestStatus::skipped($r['message']),
|
||||
@ -585,7 +555,6 @@ final class Graph
|
||||
$this->baselines[$branch]['tree'] = $tree;
|
||||
}
|
||||
|
||||
// Edges and tree snapshot stay intact; only the run-state is reset.
|
||||
public function clearResults(string $branch): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
@ -641,7 +610,6 @@ final class Graph
|
||||
$this->link($testFile, $source);
|
||||
}
|
||||
|
||||
// Deduplicate ids for this test.
|
||||
$this->edges[$testRel] = array_values(array_unique($this->edges[$testRel]));
|
||||
}
|
||||
}
|
||||
@ -702,7 +670,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Empty input is treated as a resolver failure (not "no JS pages") — keep the previous map.
|
||||
/**
|
||||
* @param array<string, array<int, string>> $fileToComponents
|
||||
*/
|
||||
@ -1086,7 +1053,6 @@ final class Graph
|
||||
return TableExtractor::fromMigrationSource($content);
|
||||
}
|
||||
|
||||
// Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux.
|
||||
private function componentForInertiaPage(string $rel): ?string
|
||||
{
|
||||
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
|
||||
@ -1275,8 +1241,6 @@ final class Graph
|
||||
return $json === false ? null : $json;
|
||||
}
|
||||
|
||||
// Accepts both absolute paths (from coverage drivers) and project-relative paths (from git diff).
|
||||
// Relative paths are NOT resolved via realpath() because CWD is not guaranteed to be the project root.
|
||||
private function relative(string $path): ?string
|
||||
{
|
||||
if ($path === '' || $path === 'unknown') {
|
||||
@ -1290,8 +1254,7 @@ final class Graph
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|
||||
|| (strlen($path) >= 2 && $path[1] === ':'); // Windows drive
|
||||
|
||||
|| (strlen($path) >= 2 && $path[1] === ':');
|
||||
if ($isAbsolute) {
|
||||
$real = @realpath($path);
|
||||
|
||||
@ -1303,7 +1266,6 @@ final class Graph
|
||||
return null;
|
||||
}
|
||||
|
||||
// Always forward slashes — git always uses them; Windows backslashes would never match.
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||
} else {
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
|
||||
|
||||
@ -16,12 +16,6 @@ final class JsImportParser
|
||||
private const string JS_DIR = 'resources/js';
|
||||
|
||||
/**
|
||||
* Walks the project's pages directory (`resources/js/Pages` or its
|
||||
* lowercase Laravel-React-starter-kit equivalent `resources/js/pages`)
|
||||
* and, for each page, collects its transitive file imports. Returns
|
||||
* the inverted graph so callers can look up "what pages depend on
|
||||
* this shared file".
|
||||
*
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public static function parse(string $projectRoot): array
|
||||
@ -142,11 +136,6 @@ final class JsImportParser
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the importable region of a file. For Vue SFCs, only the
|
||||
* `<script>` block is relevant for imports; ignoring the rest
|
||||
* avoids false-positive matches inside `<template>` attributes.
|
||||
*/
|
||||
private static function loadSource(string $fileAbs): ?string
|
||||
{
|
||||
$content = @file_get_contents($fileAbs);
|
||||
@ -169,10 +158,6 @@ final class JsImportParser
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks out every `import … from '…'` / `import '…'` / `import('…')`
|
||||
* target. We strip line comments first so a commented-out import
|
||||
* doesn't bloat the dep set.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function extractImports(string $source): array
|
||||
@ -209,9 +194,6 @@ final class JsImportParser
|
||||
return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail));
|
||||
}
|
||||
|
||||
// Anything else is either a node_modules package or an
|
||||
// unrecognised alias — skip. The watch-pattern fallback
|
||||
// handles the safety-net case for non-matched paths.
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -227,10 +209,6 @@ final class JsImportParser
|
||||
return self::withExtension($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports may omit the extension or point at a directory (index.vue,
|
||||
* index.ts). Probe the common targets in order.
|
||||
*/
|
||||
private static function withExtension(string $path): ?string
|
||||
{
|
||||
if (is_file($path)) {
|
||||
|
||||
@ -16,30 +16,14 @@ final class JsModuleGraph
|
||||
|
||||
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 instanceof Process || self::$warmerCacheHit) {
|
||||
@ -76,7 +60,7 @@ final class JsModuleGraph
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>> project-relative source path → sorted list of page component names
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public static function build(string $projectRoot): array
|
||||
{
|
||||
@ -86,13 +70,6 @@ final class JsModuleGraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant — returns null when the Node resolver isn't
|
||||
* available, so callers can distinguish "Vite says nothing imports
|
||||
* this file" (empty list) from "we couldn't ask Vite" (null).
|
||||
*
|
||||
* Used at replay time when we need to *trust a negative result*
|
||||
* (i.e., "no page imports this file, so it's orphan, safe to skip").
|
||||
*
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
public static function buildStrict(string $projectRoot): ?array
|
||||
@ -100,22 +77,12 @@ final class JsModuleGraph
|
||||
return self::resolve($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the project looks like a Vite + Node project we can
|
||||
* ask for a module graph. Gate for callers that want to skip the
|
||||
* resolver entirely on non-Vite apps.
|
||||
*/
|
||||
public static function isApplicable(string $projectRoot): bool
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React
|
||||
// starter kit (`pages/`) conventions are accepted — projects
|
||||
// running on a case-sensitive filesystem (Linux CI) get
|
||||
// exactly one of the two, and we shouldn't refuse to walk the
|
||||
// graph based on which one it picks.
|
||||
foreach (['Pages', 'pages'] as $dir) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
|
||||
return true;
|
||||
@ -142,14 +109,7 @@ final class JsModuleGraph
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (self::$warmerCacheHit && $fingerprint !== null) {
|
||||
$cached = self::readCache($projectRoot, $fingerprint);
|
||||
self::$warmerCacheHit = false;
|
||||
self::$warmerFingerprint = null;
|
||||
@ -160,60 +120,28 @@ final class JsModuleGraph
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$warmer instanceof Process
|
||||
&& self::$warmerFingerprint === $fingerprint
|
||||
&& self::$warmerProjectRoot === $projectRoot) {
|
||||
$process = self::$warmer;
|
||||
self::$warmer = null;
|
||||
self::$warmerFingerprint = null;
|
||||
self::$warmerProjectRoot = null;
|
||||
|
||||
$process->wait();
|
||||
|
||||
if ($process instanceof Process && $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 instanceof Process) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process->run();
|
||||
$process->wait();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::parseNodeOutput($process->getOutput());
|
||||
$result = self::parseNodeOutput($process->getOutput());
|
||||
|
||||
if ($result !== null && $fingerprint !== null) {
|
||||
self::writeCache($projectRoot, $fingerprint, $result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function buildNodeProcess(string $projectRoot): ?Process
|
||||
@ -238,11 +166,6 @@ final class JsModuleGraph
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tell the Node helper which casing this project uses for its
|
||||
// pages directory. The helper defaults to `resources/js/Pages`;
|
||||
// the Laravel React starter ships lowercase `resources/js/pages`,
|
||||
// and on a case-sensitive filesystem the helper would otherwise
|
||||
// walk a non-existent directory and emit an empty module graph.
|
||||
$env = [];
|
||||
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
|
||||
@ -298,10 +221,6 @@ final class JsModuleGraph
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and discard a leftover warmer subprocess (e.g. on shutdown,
|
||||
* or when `build()` resolved from cache without needing the warmer).
|
||||
*/
|
||||
private static function reapWarmer(): void
|
||||
{
|
||||
$process = self::$warmer;
|
||||
|
||||
@ -33,9 +33,6 @@ final class Recorder
|
||||
/** @var array<string, bool> */
|
||||
private array $classUsesDatabaseCache = [];
|
||||
|
||||
// Source file → declared class names. Built incrementally as classes are autoloaded.
|
||||
// Used to walk the interface/trait/parent hierarchy which coverage drivers miss
|
||||
// (interfaces and empty traits emit no executable bytecode).
|
||||
/** @var array<string, list<string>> */
|
||||
private array $fileToClassNames = [];
|
||||
|
||||
@ -78,8 +75,6 @@ final class Recorder
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
||||
// Probing with start/stop emits E_WARNING when coverage is off, which monitoring agents
|
||||
// (Sentry, Bugsnag) can surface as a real error. xdebug_info('mode') is silent.
|
||||
$modes = \xdebug_info('mode');
|
||||
|
||||
if (is_array($modes) && in_array('coverage', $modes, true)) {
|
||||
@ -124,8 +119,6 @@ final class Recorder
|
||||
$this->perTestUsesDatabase[$file] = true;
|
||||
}
|
||||
|
||||
// Walk parent-class chain to link ancestor files. Empty base classes (e.g. a trait-only
|
||||
// TestCase) emit no executable bytecode, so the coverage driver never records them.
|
||||
$this->linkAncestorFiles($className);
|
||||
$this->linkImportedFiles($file);
|
||||
|
||||
@ -136,7 +129,6 @@ final class Recorder
|
||||
return;
|
||||
}
|
||||
|
||||
// Xdebug
|
||||
\xdebug_start_code_coverage();
|
||||
}
|
||||
|
||||
@ -149,15 +141,6 @@ final class Recorder
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\stop();
|
||||
|
||||
// pcov\waiting() lists every file pcov has tracked but not
|
||||
// yet collected for. Filter that list down to the project's
|
||||
// source scope (phpunit.xml's `<source>` plus other
|
||||
// top-level project dirs, minus vendor / caches), then ask
|
||||
// pcov to collect *only* for those — `pcov\inclusive`
|
||||
// narrows the result set at the driver level instead of us
|
||||
// post-filtering after a full collect. Anything pcov saw
|
||||
// outside the scope is dropped before any line counts come
|
||||
// back.
|
||||
$scope = $this->sourceScope();
|
||||
$filesToCollectCoverageFor = [];
|
||||
|
||||
@ -174,7 +157,6 @@ final class Recorder
|
||||
} else {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \xdebug_get_code_coverage();
|
||||
// `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
|
||||
\xdebug_stop_code_coverage(true);
|
||||
|
||||
$coveredFiles = array_keys($data);
|
||||
@ -193,8 +175,6 @@ final class Recorder
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
// Walk covered classes' interfaces/traits/parents. Interfaces have no executable bytecode,
|
||||
// so a signature change would leave implementing-class tests stale without this walk.
|
||||
$this->linkSourceDependencies($coveredFiles);
|
||||
|
||||
$this->currentTestFile = null;
|
||||
@ -334,7 +314,6 @@ final class Recorder
|
||||
$files[$f] = true;
|
||||
};
|
||||
|
||||
// getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices.
|
||||
foreach ($reflection->getInterfaceNames() as $iname) {
|
||||
$linkSymbol($iname);
|
||||
}
|
||||
@ -639,8 +618,6 @@ final class Recorder
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefers Pest's `$__filename` static (the original .php file) over ReflectionClass::getFileName()
|
||||
// (which returns the trait file for methods brought in via `uses SharedTestBehavior`).
|
||||
private function readPestFilename(string $className): ?string
|
||||
{
|
||||
if (! class_exists($className, false)) {
|
||||
@ -667,17 +644,6 @@ final class Recorder
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters pcov's `file => line => executionCount` map to files that
|
||||
* actually had executed code AND live inside the configured source
|
||||
* scope (`phpunit.xml`'s `<source>` block, or the project root with
|
||||
* vendor/etc. excluded as fallback).
|
||||
*
|
||||
* pcov reports `-1` for "executable but not run" and a positive
|
||||
* count for executed lines. We also skip files where the *only*
|
||||
* positive line is the implicit `ZEND_RETURN` at end-of-file: pcov
|
||||
* surfaces that as a one-line artifact for files that were merely
|
||||
* included (autoloaded) without any real code running.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<string>
|
||||
*/
|
||||
@ -700,9 +666,6 @@ final class Recorder
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip files where the only "executed" line is the implicit
|
||||
// ZEND_RETURN at end-of-file (pcov artifact from being included
|
||||
// but never actually run).
|
||||
$lineKeys = array_keys($lines);
|
||||
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
|
||||
continue;
|
||||
|
||||
@ -97,10 +97,6 @@ final class ResultCollector
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects externally-collected results (e.g. partials flushed by parallel
|
||||
* workers) into this collector so the parent can persist them in the same
|
||||
* snapshot pass as non-parallel runs.
|
||||
*
|
||||
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
|
||||
*/
|
||||
public function merge(array $results): void
|
||||
@ -118,11 +114,6 @@ final class ResultCollector
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Finished subscriber after a test's outcome + assertion
|
||||
* events have all fired. Clears the "currently recording" pointer so
|
||||
* the next test's events don't get mis-attributed.
|
||||
*/
|
||||
public function finishTest(): void
|
||||
{
|
||||
$this->currentTestId = null;
|
||||
@ -140,10 +131,6 @@ final class ResultCollector
|
||||
? round(microtime(true) - $this->startTime, 3)
|
||||
: 0.0;
|
||||
|
||||
// PHPUnit can fire more than one outcome event per test — the
|
||||
// canonical case is a risky pass (`Passed` then `ConsideredRisky`).
|
||||
// Last-wins semantics preserve the most specific status; the
|
||||
// existing assertion count (if any) survives the overwrite.
|
||||
$existing = $this->results[$this->currentTestId] ?? null;
|
||||
|
||||
$this->results[$this->currentTestId] = [
|
||||
|
||||
@ -9,11 +9,6 @@ namespace Pest\Plugins\Tia;
|
||||
*/
|
||||
final readonly class SourceScope
|
||||
{
|
||||
/**
|
||||
* Top-level directory names always treated as out-of-scope. These
|
||||
* mirror what a Laravel app considers "not source": dependencies,
|
||||
* editor metadata, framework artefacts, the TIA state itself.
|
||||
*/
|
||||
private const array TOP_LEVEL_NOISE = [
|
||||
'vendor',
|
||||
'node_modules',
|
||||
@ -26,13 +21,6 @@ final readonly class SourceScope
|
||||
'.cache',
|
||||
];
|
||||
|
||||
/**
|
||||
* Nested paths (relative to project root) that must be excluded
|
||||
* even when their top-level parent is in scope. Laravel writes
|
||||
* compiled views, route caches, and packaged manifests here on
|
||||
* every framework boot — instrumenting them would burn cycles
|
||||
* and create noisy edges.
|
||||
*/
|
||||
private const array NESTED_NOISE = [
|
||||
'storage/framework',
|
||||
'storage/logs',
|
||||
@ -80,12 +68,6 @@ final readonly class SourceScope
|
||||
return new self($includes, $excludes);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the absolute file path is inside an `<include>`
|
||||
* directory and not under any exclude. Symlinks are resolved on
|
||||
* the input so a `realpath()`'d coverage entry still matches a
|
||||
* config that pointed at the unresolved tree.
|
||||
*/
|
||||
public function contains(string $absoluteFile): bool
|
||||
{
|
||||
$real = @realpath($absoluteFile);
|
||||
@ -108,10 +90,6 @@ final readonly class SourceScope
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-relative directories the resolver considers in scope.
|
||||
* Useful for setting `pcov.directory` (a single common ancestor)
|
||||
* or `\pcov\collect()`'s file filter.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function includes(): array
|
||||
@ -159,11 +137,6 @@ final readonly class SourceScope
|
||||
}
|
||||
|
||||
/**
|
||||
* Every top-level directory under `$projectRoot` except those on
|
||||
* the noise list. Hidden entries (dotdirs) are skipped unless
|
||||
* they're explicitly project source — keeping `.git/`, `.idea/`
|
||||
* etc. out without an explicit allowlist.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function topLevelProjectDirs(string $projectRoot): array
|
||||
@ -228,8 +201,6 @@ final readonly class SourceScope
|
||||
$real = @realpath($combined);
|
||||
|
||||
if ($real === false) {
|
||||
// Directory may not exist yet (e.g. generated source) — keep
|
||||
// the unresolved path so a future file under it still matches.
|
||||
return self::normalise($combined);
|
||||
}
|
||||
|
||||
|
||||
@ -9,9 +9,6 @@ namespace Pest\Plugins\Tia;
|
||||
*/
|
||||
final class Storage
|
||||
{
|
||||
/**
|
||||
* Directory where TIA's State blobs live for `$projectRoot`.
|
||||
*/
|
||||
public static function tempDir(string $projectRoot): string
|
||||
{
|
||||
$home = self::homeDir();
|
||||
@ -28,15 +25,6 @@ final class Storage
|
||||
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipes the on-disk state directory for `$projectRoot`. Called by
|
||||
* `--fresh` so a rebuild starts from a truly empty cache: no stale
|
||||
* baseline, no leftover worker partials, no fingerprint, no JS
|
||||
* module cache. Subsequent writes recreate the directory on demand.
|
||||
*
|
||||
* Per-project (project key is part of the path) — sibling projects'
|
||||
* caches under `~/.pest/tia/` are untouched.
|
||||
*/
|
||||
public static function purge(string $projectRoot): void
|
||||
{
|
||||
$dir = self::tempDir($projectRoot);
|
||||
@ -77,11 +65,6 @@ final class Storage
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on
|
||||
* Windows. Returns null if neither resolves to an existing
|
||||
* directory, in which case callers fall back to project-local state.
|
||||
*/
|
||||
private static function homeDir(): ?string
|
||||
{
|
||||
foreach (['HOME', 'USERPROFILE'] as $key) {
|
||||
@ -96,27 +79,7 @@ final class Storage
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder name for `$projectRoot` under `~/.pest/tia/`.
|
||||
*
|
||||
* Strategy — each step rules out a class of collision:
|
||||
*
|
||||
* 1. If the project has a git origin URL, use a **normalised** form
|
||||
* (`host/org/repo`, lowercased, no `.git` suffix) as the input.
|
||||
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
|
||||
* and `https://github.com/foo/bar` all collapse to
|
||||
* `github.com/foo/bar` — three developers cloning the same repo
|
||||
* by different transports share one cache, which is what we want.
|
||||
* 2. Otherwise, use the canonicalised absolute path (`realpath`).
|
||||
* Two unrelated `app/` checkouts under different parent folders
|
||||
* have different realpaths → different hashes → isolated.
|
||||
* 3. Hash the chosen input with sha256 and keep the first 16 hex
|
||||
* chars — 64 bits of entropy makes accidental collision
|
||||
* astronomically unlikely even across thousands of projects.
|
||||
* 4. Prefix with a slug of the project basename so `ls ~/.pest/tia/`
|
||||
* is readable; the slug is cosmetic only, all isolation comes
|
||||
* from the hash.
|
||||
*
|
||||
* Result: `myapp-a1b2c3d4e5f67890`.
|
||||
*/
|
||||
private static function projectKey(string $projectRoot): string
|
||||
{
|
||||
@ -131,12 +94,6 @@ final class Storage
|
||||
return $slug === '' ? $hash : $slug.'-'.$hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical git origin identity for `$projectRoot`, or null when
|
||||
* no origin URL can be parsed. The returned form is
|
||||
* `host/org/repo` (lowercased, `.git` stripped) so SSH / HTTPS / git
|
||||
* protocol clones of the same remote produce the same value.
|
||||
*/
|
||||
private static function originIdentity(string $projectRoot): ?string
|
||||
{
|
||||
$url = self::rawOriginUrl($projectRoot);
|
||||
@ -155,8 +112,6 @@ final class Storage
|
||||
return strtolower($m[1].'/'.$m[2]);
|
||||
}
|
||||
|
||||
// Unrecognised form — hash the raw URL so different inputs still
|
||||
// diverge, but lowercased so the only variance is intentional.
|
||||
return strtolower($url);
|
||||
}
|
||||
|
||||
@ -181,11 +136,6 @@ final class Storage
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem-safe kebab of `$name`. Cosmetic only — used as a
|
||||
* human-readable prefix on the hash so `~/.pest/tia/` lists
|
||||
* recognisable folders.
|
||||
*/
|
||||
private static function slug(string $name): string
|
||||
{
|
||||
$slug = strtolower($name);
|
||||
|
||||
@ -9,18 +9,10 @@ namespace Pest\Plugins\Tia;
|
||||
*/
|
||||
final class TableExtractor
|
||||
{
|
||||
/**
|
||||
* DML prefixes we accept. DDL (`CREATE`, `ALTER`, `DROP`,
|
||||
* `TRUNCATE`, `RENAME`) is deliberately excluded — those come
|
||||
* from migrations fired by `RefreshDatabase`, and capturing them
|
||||
* here would attribute every migration table to every test.
|
||||
*/
|
||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
|
||||
|
||||
/**
|
||||
* @return list<string> Sorted, deduped table names referenced by the
|
||||
* SQL statement. Empty when the statement is
|
||||
* DDL, empty, or unparseable.
|
||||
*/
|
||||
public static function fromSql(string $sql): array
|
||||
{
|
||||
@ -45,9 +37,6 @@ final class TableExtractor
|
||||
return [];
|
||||
}
|
||||
|
||||
// Match `from`, `into`, `update`, `join` and capture the
|
||||
// following identifier, tolerating the common quoting
|
||||
// styles: "double", `back`, [bracket], or bare.
|
||||
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
|
||||
|
||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||
@ -82,35 +71,11 @@ final class TableExtractor
|
||||
|
||||
/**
|
||||
* @return list<string> Table names referenced by `Schema::` calls,
|
||||
* raw DDL, or DML inside the given migration
|
||||
* file contents. Empty when nothing matches —
|
||||
* callers treat that as "fall back to the
|
||||
* broad watch pattern".
|
||||
*
|
||||
* Three passes:
|
||||
* 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename`
|
||||
* captures the conventional Laravel migration shape.
|
||||
* 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP /
|
||||
* TRUNCATE / RENAME TABLE <name>` patterns inside string
|
||||
* literals (i.e. `DB::statement('CREATE TABLE …')`,
|
||||
* `DB::unprepared('ALTER TABLE …')`).
|
||||
* 3. DML inside migration bodies — `INSERT INTO`, `UPDATE … SET`,
|
||||
* `DELETE FROM`, and Laravel's fluent `DB::table('foo')`.
|
||||
* Catches the seeded-lookup-table case where a migration
|
||||
* populates rows that tests later read.
|
||||
*
|
||||
* False positives possible when the same syntax appears in a
|
||||
* comment or unrelated string, but over-attribution is
|
||||
* correctness-safe.
|
||||
*/
|
||||
public static function fromMigrationSource(string $php): array
|
||||
{
|
||||
$tables = [];
|
||||
|
||||
// Pass 1: Schema:: calls. `dropColumn` (singular) covers
|
||||
// `Schema::table('users', fn ($t) => $t->dropColumn('foo'))`
|
||||
// — the closure body's column op is on Blueprint, but the
|
||||
// outer `Schema::table('users', …)` is what we capture here.
|
||||
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
|
||||
|
||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||
@ -124,10 +89,6 @@ final class TableExtractor
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: raw DDL fallback. Matches the table name following
|
||||
// `CREATE/ALTER/DROP/TRUNCATE/RENAME TABLE` (plus Postgres'
|
||||
// `IF EXISTS` / `IF NOT EXISTS` variants), with optional
|
||||
// ANSI / MySQL / SQL Server quoting.
|
||||
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
||||
|
||||
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
||||
@ -139,14 +100,6 @@ final class TableExtractor
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: DML inside migration bodies. Migrations that seed
|
||||
// lookup tables via `DB::statement('INSERT INTO roles …')`,
|
||||
// `DB::table('statuses')->insert(…)`, `UPDATE foo SET …`, or
|
||||
// `DELETE FROM bar` are common in Laravel. Without picking
|
||||
// these up, an edit to the seed payload would route through
|
||||
// only the schema'd tables and silently skip every test that
|
||||
// reads from the populated table. Fluent-builder calls
|
||||
// (`DB::table('x')`) and raw SQL strings are both covered.
|
||||
$dmlPatterns = [
|
||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
|
||||
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
|
||||
@ -172,11 +125,6 @@ final class TableExtractor
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out driver-internal tables that show up as DB::listen
|
||||
* targets without representing user schema: SQLite's master
|
||||
* catalogue, Laravel's own `migrations` metadata.
|
||||
*/
|
||||
private static function isSchemaMeta(string $name): bool
|
||||
{
|
||||
$lower = strtolower($name);
|
||||
|
||||
@ -11,13 +11,6 @@ final class TableTracker
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
/**
|
||||
* App-scoped marker that makes `arm()` idempotent across the 774
|
||||
* per-test `setUp()` calls — Laravel reuses the same app instance
|
||||
* within a single test run, so without this guard we'd stack
|
||||
* one listener per test and each query would fire the closure
|
||||
* hundreds of times.
|
||||
*/
|
||||
private const string MARKER = 'pest.tia.table-tracker-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
@ -66,12 +59,6 @@ final class TableTracker
|
||||
}
|
||||
};
|
||||
|
||||
// Preferred path: `DatabaseManager::listen(Closure $callback)`.
|
||||
// It's a real method — `method_exists` returns false because
|
||||
// some Laravel versions compose it via a trait the reflection
|
||||
// probe can't always see, so we gate via `is_callable` instead.
|
||||
// This path pushes the listener onto every existing AND future
|
||||
// connection, which is what we want for a process-wide capture.
|
||||
/** @var object $db */
|
||||
$db = $app->make('db');
|
||||
|
||||
@ -83,11 +70,6 @@ final class TableTracker
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: register directly on the event dispatcher. Works
|
||||
// as long as every connection shares the same dispatcher
|
||||
// instance this app resolved to — true in vanilla setups,
|
||||
// but not guaranteed with connections instantiated pre-arm
|
||||
// that captured an older dispatcher.
|
||||
if (! $app->bound('events')) {
|
||||
return;
|
||||
}
|
||||
@ -99,11 +81,6 @@ final class TableTracker
|
||||
return;
|
||||
}
|
||||
|
||||
// Event class key intentionally has no leading backslash —
|
||||
// `Dispatcher::listen()` stores by the literal string and the
|
||||
// lookup at dispatch time uses `get_class($event)` (no
|
||||
// leading backslash), so a `\Illuminate\…` key would never
|
||||
// match the fired event.
|
||||
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,9 +16,6 @@ final readonly class Browser implements WatchDefault
|
||||
{
|
||||
public function applicable(): bool
|
||||
{
|
||||
// Browser tests can exist in any PHP project. We only activate when
|
||||
// there is an actual `tests/Browser` directory OR pest-plugin-browser
|
||||
// is installed.
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
|
||||
}
|
||||
@ -37,12 +34,8 @@ final readonly class Browser implements WatchDefault
|
||||
'resources/css/**/*.css',
|
||||
'resources/css/**/*.scss',
|
||||
'resources/css/**/*.less',
|
||||
// Vite / Webpack build output that browser tests may consume.
|
||||
'public/build/**/*.js',
|
||||
'public/build/**/*.css',
|
||||
// Static public assets can affect browser-rendered pages without
|
||||
// any PHP file changing (favicons, robots, images, downloaded
|
||||
// manifests, etc.). Only browser-test targets are invalidated.
|
||||
'public/**/*.js',
|
||||
'public/**/*.css',
|
||||
'public/**/*.svg',
|
||||
@ -79,9 +72,6 @@ final readonly class Browser implements WatchDefault
|
||||
$targets[] = $candidate;
|
||||
}
|
||||
|
||||
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
|
||||
// is installed to find exact tests using `visit()` outside the
|
||||
// conventional Browser/ folder.
|
||||
if (class_exists(BrowserTestIdentifier::class)) {
|
||||
$repo = TestSuite::getInstance()->tests;
|
||||
|
||||
|
||||
@ -22,19 +22,6 @@ final readonly class Inertia implements WatchDefault
|
||||
{
|
||||
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
|
||||
|
||||
// Inertia page components (React / Vue / Svelte). Scoped to
|
||||
// browser tests only — a Vue/React edit cannot change the
|
||||
// output of a server-side Inertia test (those assert on the
|
||||
// component *name* returned by `Inertia::render()`, not its
|
||||
// client-side implementation). Broad invalidation is only
|
||||
// meaningful for tests that actually render the DOM. Precise
|
||||
// per-component edges come from `InertiaEdges` at record
|
||||
// time and replace this fallback when available.
|
||||
//
|
||||
// Both `Pages/` (classic Inertia-Vue) and `pages/` (Laravel
|
||||
// React starter kit, and other lowercase-by-default setups)
|
||||
// are emitted — paths from git are case-sensitive on Linux,
|
||||
// so a single casing would silently miss the other convention.
|
||||
$patterns = [];
|
||||
|
||||
foreach (['Pages', 'pages'] as $pages) {
|
||||
@ -49,7 +36,6 @@ final readonly class Inertia implements WatchDefault
|
||||
}
|
||||
}
|
||||
|
||||
// SSR entry point.
|
||||
$patterns['resources/js/ssr.js'] = $browserTargets;
|
||||
$patterns['resources/js/ssr.ts'] = $browserTargets;
|
||||
$patterns['resources/js/app.js'] = $browserTargets;
|
||||
|
||||
@ -20,42 +20,23 @@ final readonly class Laravel implements WatchDefault
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
return [
|
||||
// Config — loaded during app boot (setUp), invisible to coverage.
|
||||
// Affects both Feature and Unit: Pest.php commonly binds fakes
|
||||
// and seeds DB based on config values.
|
||||
'config/*.php' => [$testPath],
|
||||
'config/**/*.php' => [$testPath],
|
||||
|
||||
// Routes — loaded during boot. HTTP/Feature tests depend on them.
|
||||
'routes/*.php' => [$testPath],
|
||||
'routes/**/*.php' => [$testPath],
|
||||
|
||||
// Service providers / bootstrap — loaded during boot, affect
|
||||
// bindings, middleware, event listeners, scheduled tasks.
|
||||
'bootstrap/app.php' => [$testPath],
|
||||
'bootstrap/providers.php' => [$testPath],
|
||||
|
||||
// Migrations — run via RefreshDatabase/FastRefreshDatabase in
|
||||
// setUp. Schema changes can break any test that touches DB.
|
||||
'database/migrations/**/*.php' => [$testPath],
|
||||
|
||||
// Seeders — often run globally via Pest.php beforeEach.
|
||||
'database/seeders/**/*.php' => [$testPath],
|
||||
|
||||
// Factories — loaded lazily but still PHP that coverage may miss
|
||||
// if the factory file was already autoloaded before Prepared.
|
||||
'database/factories/**/*.php' => [$testPath],
|
||||
|
||||
// Project fixture data. Laravel apps often keep fake repository
|
||||
// lockfiles / API payloads here and read them via `storage_path()`
|
||||
// + `file_get_contents()`, which neither PHP coverage nor static
|
||||
// import edges can observe.
|
||||
'storage/fixtures/**/*' => [$testPath],
|
||||
|
||||
// Non-PHP templates/data living beside app code. These are often
|
||||
// read dynamically by services (Dockerfile templates, stubs,
|
||||
// payload examples) and never appear in coverage because PHP only
|
||||
// sees the reader method, not the external file.
|
||||
'app/**/*.tpl' => [$testPath],
|
||||
'app/**/*.stub' => [$testPath],
|
||||
'app/**/*.json' => [$testPath],
|
||||
@ -63,25 +44,16 @@ final readonly class Laravel implements WatchDefault
|
||||
'app/**/*.yml' => [$testPath],
|
||||
'app/**/*.txt' => [$testPath],
|
||||
|
||||
// Blade templates — compiled to cache, source file not executed.
|
||||
'resources/views/**/*.blade.php' => [$testPath],
|
||||
// Mail / view-adjacent themes can be read dynamically by
|
||||
// mailables (for example Laravel's markdown mail theme CSS).
|
||||
'resources/views/**/*.css' => [$testPath],
|
||||
// Email templates are nested under views/email or views/emails
|
||||
// by convention and power mailable tests that render markup.
|
||||
'resources/views/email/**/*.blade.php' => [$testPath],
|
||||
'resources/views/emails/**/*.blade.php' => [$testPath],
|
||||
|
||||
// Translations — JSON translations read via file_get_contents,
|
||||
// PHP translations loaded via include (but during boot).
|
||||
'lang/**/*.php' => [$testPath],
|
||||
'lang/**/*.json' => [$testPath],
|
||||
'resources/lang/**/*.php' => [$testPath],
|
||||
'resources/lang/**/*.json' => [$testPath],
|
||||
|
||||
// Build tool config — affects compiled assets consumed by
|
||||
// browser and Inertia tests.
|
||||
'vite.config.js' => [$testPath],
|
||||
'vite.config.ts' => [$testPath],
|
||||
'webpack.mix.js' => [$testPath],
|
||||
|
||||
@ -20,15 +20,10 @@ final readonly class Livewire implements WatchDefault
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
return [
|
||||
// Livewire views live alongside Blade views or in a dedicated dir.
|
||||
'resources/views/livewire/**/*.blade.php' => [$testPath],
|
||||
'resources/views/components/**/*.blade.php' => [$testPath],
|
||||
// Volt's second default mount — single-file components used as
|
||||
// full-page routes. Missing this means editing a Volt page
|
||||
// doesn't re-run its tests.
|
||||
'resources/views/pages/**/*.blade.php' => [$testPath],
|
||||
|
||||
// Livewire JS interop / Alpine plugins.
|
||||
'resources/js/**/*.js' => [$testPath],
|
||||
'resources/js/**/*.ts' => [$testPath],
|
||||
];
|
||||
|
||||
@ -16,55 +16,24 @@ final readonly class Php implements WatchDefault
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
// NOTE: composer.json / composer.lock changes are caught by the
|
||||
// fingerprint (which hashes composer.lock). PHP files are tracked by
|
||||
// the coverage driver. Only non-PHP, non-fingerprinted files that
|
||||
// can silently alter test behaviour belong here.
|
||||
|
||||
return [
|
||||
// Environment files — can change DB drivers, feature flags,
|
||||
// queue connections, etc. Not PHP, not fingerprinted. Covers
|
||||
// the local-override variants (`.env.local`, `.env.testing.local`)
|
||||
// that both Laravel and Symfony recommend for machine-specific
|
||||
// config.
|
||||
'.env' => [$testPath],
|
||||
'.env.testing' => [$testPath],
|
||||
'.env.local' => [$testPath],
|
||||
'.env.*.local' => [$testPath],
|
||||
|
||||
// Docker / CI — can affect integration test infrastructure.
|
||||
'docker-compose.yml' => [$testPath],
|
||||
'docker-compose.yaml' => [$testPath],
|
||||
|
||||
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but
|
||||
// phpunit.xml.dist and other XML overrides are not individually
|
||||
// tracked by the coverage driver.
|
||||
'phpunit.xml.dist' => [$testPath],
|
||||
|
||||
// `tests/Pest.php` is loaded once per suite (during BootFiles)
|
||||
// so its `pest()->extend()`, `expect()->extend()`, helpers,
|
||||
// etc. execute outside the per-test coverage window — no
|
||||
// edge captures it. Watch-pattern broadcast triggers a
|
||||
// replay of every test (results refresh) without a full
|
||||
// record-mode graph rebuild.
|
||||
$testPath.'/Pest.php' => [$testPath],
|
||||
|
||||
// Pest dataset definitions are loaded once at boot, outside
|
||||
// the per-test coverage window — no edge captures them. A
|
||||
// change to a shared dataset can flip the result of any test
|
||||
// that uses it, so broadcast every dataset edit to the full
|
||||
// suite.
|
||||
$testPath.'/Datasets/**/*.php' => [$testPath],
|
||||
|
||||
// Test fixtures — data/source snippets consumed by assertions or
|
||||
// external analysers. Nested `Fixtures/` directories are common
|
||||
// beside a single test class, and PHP fixtures may be parsed by
|
||||
// tools without being `require`d, so coverage cannot see them.
|
||||
$testPath.'/Fixtures/**/*' => [$testPath],
|
||||
$testPath.'/**/Fixtures/**/*' => [$testPath],
|
||||
|
||||
// Pest snapshots — external edits to snapshot files invalidate
|
||||
// snapshot assertions.
|
||||
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
|
||||
];
|
||||
}
|
||||
|
||||
@ -19,12 +19,7 @@ final readonly class Symfony implements WatchDefault
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
// Symfony boots the kernel in setUp() (before the coverage window).
|
||||
// PHP config, routes, kernel, and migrations are loaded during boot
|
||||
// and invisible to the coverage driver. Same reasoning as Laravel.
|
||||
|
||||
return [
|
||||
// Config — YAML, XML, and PHP. All loaded during kernel boot.
|
||||
'config/*.yaml' => [$testPath],
|
||||
'config/*.yml' => [$testPath],
|
||||
'config/*.php' => [$testPath],
|
||||
@ -34,37 +29,27 @@ final readonly class Symfony implements WatchDefault
|
||||
'config/**/*.php' => [$testPath],
|
||||
'config/**/*.xml' => [$testPath],
|
||||
|
||||
// Routes — loaded during boot.
|
||||
'config/routes/*.yaml' => [$testPath],
|
||||
'config/routes/*.php' => [$testPath],
|
||||
'config/routes/*.xml' => [$testPath],
|
||||
'config/routes/**/*.yaml' => [$testPath],
|
||||
|
||||
// Kernel / bootstrap — loaded during boot.
|
||||
'src/Kernel.php' => [$testPath],
|
||||
|
||||
// Migrations — run during setUp (before coverage window).
|
||||
// DoctrineMigrationsBundle's default is `migrations/` at the
|
||||
// project root; many Symfony projects relocate to
|
||||
// `src/Migrations/` — both covered.
|
||||
'migrations/**/*.php' => [$testPath],
|
||||
'src/Migrations/**/*.php' => [$testPath],
|
||||
|
||||
// Twig templates — compiled, source not PHP-executed.
|
||||
'templates/**/*.html.twig' => [$testPath],
|
||||
'templates/**/*.twig' => [$testPath],
|
||||
|
||||
// Translations (YAML / XLF / XLIFF).
|
||||
'translations/**/*.yaml' => [$testPath],
|
||||
'translations/**/*.yml' => [$testPath],
|
||||
'translations/**/*.xlf' => [$testPath],
|
||||
'translations/**/*.xliff' => [$testPath],
|
||||
|
||||
// Doctrine XML/YAML mappings.
|
||||
'config/doctrine/**/*.xml' => [$testPath],
|
||||
'config/doctrine/**/*.yaml' => [$testPath],
|
||||
|
||||
// Webpack Encore / asset-mapper config + frontend sources.
|
||||
'webpack.config.js' => [$testPath],
|
||||
'importmap.php' => [$testPath],
|
||||
'assets/**/*.js' => [$testPath],
|
||||
|
||||
@ -9,9 +9,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
*/
|
||||
interface WatchDefault
|
||||
{
|
||||
/**
|
||||
* Whether this default set applies to the current project.
|
||||
*/
|
||||
public function applicable(): bool;
|
||||
|
||||
/**
|
||||
|
||||
@ -13,8 +13,6 @@ use Pest\TestSuite;
|
||||
final class WatchPatterns
|
||||
{
|
||||
/**
|
||||
* All known default providers, in evaluation order.
|
||||
*
|
||||
* @var array<int, class-string<WatchDefault>>
|
||||
*/
|
||||
private const array DEFAULTS = [
|
||||
@ -37,12 +35,6 @@ final class WatchPatterns
|
||||
|
||||
private bool $filtered = false;
|
||||
|
||||
/**
|
||||
* Probes every registered `WatchDefault` and merges the patterns of
|
||||
* those that apply. Called once during Tia plugin boot, after BootFiles
|
||||
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
|
||||
* calls are already in `$this->patterns`).
|
||||
*/
|
||||
public function useDefaults(string $projectRoot): void
|
||||
{
|
||||
$testPath = TestSuite::getInstance()->testPath;
|
||||
@ -63,9 +55,6 @@ final class WatchPatterns
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds user-defined patterns. Merges with existing entries so a single
|
||||
* glob can map to multiple directories.
|
||||
*
|
||||
* @param array<string, string> $patterns glob → project-relative test dir/file
|
||||
*/
|
||||
public function add(array $patterns): void
|
||||
@ -78,9 +67,6 @@ final class WatchPatterns
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all test targets whose watch patterns match at least one of
|
||||
* the given changed files.
|
||||
*
|
||||
* @param string $projectRoot Absolute path.
|
||||
* @param array<int, string> $changedFiles Project-relative paths.
|
||||
* @return array<int, string> Project-relative test dirs/files.
|
||||
@ -107,9 +93,6 @@ final class WatchPatterns
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the affected targets, returns every test file in the graph that
|
||||
* either matches an exact file target or lives under a directory target.
|
||||
*
|
||||
* @param array<int, string> $directories Project-relative dirs/files.
|
||||
* @param array<int, string> $allTestFiles Project-relative test files from graph.
|
||||
* @return array<int, string>
|
||||
@ -181,11 +164,6 @@ final class WatchPatterns
|
||||
$this->filtered = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a project-relative file against a glob pattern.
|
||||
*
|
||||
* Supports `*` (single segment), `**` (any depth) and `?`.
|
||||
*/
|
||||
private function globMatches(string $pattern, string $file): bool
|
||||
{
|
||||
$pattern = str_replace('\\', '/', $pattern);
|
||||
|
||||
@ -40,11 +40,6 @@ final class XdebugRestarter implements Restarter
|
||||
(new XdebugHandler('pest'))->check();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when Xdebug 3+ is running in coverage-only mode (or empty). False
|
||||
* for older Xdebug without `xdebug_info` — be conservative and leave it
|
||||
* loaded; we can't prove the mode is safe to drop.
|
||||
*/
|
||||
private function xdebugIsCoverageOnly(): bool
|
||||
{
|
||||
if (! function_exists('xdebug_info')) {
|
||||
@ -67,11 +62,6 @@ final class XdebugRestarter implements Restarter
|
||||
}
|
||||
|
||||
/**
|
||||
* TIA must be enabled for this run, no coverage flag, no forced
|
||||
* rebuild, and TIA must be about to replay rather than record. Plain
|
||||
* `pest` (and anything else without TIA enabled) keeps Xdebug loaded
|
||||
* so non-TIA users aren't surprised by behaviour changes.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
private function runLooksDroppable(array $arguments, string $projectRoot): bool
|
||||
@ -95,12 +85,6 @@ final class XdebugRestarter implements Restarter
|
||||
return $this->tiaWillReplay($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a valid TIA graph already lives on disk AND its structural
|
||||
* fingerprint matches the current environment. Any other outcome
|
||||
* (missing graph, unreadable JSON, structural drift) means TIA will
|
||||
* record and the driver must stay loaded.
|
||||
*/
|
||||
private function tiaWillReplay(string $projectRoot): bool
|
||||
{
|
||||
$path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
|
||||
|
||||
@ -27,10 +27,6 @@ final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements Finishe
|
||||
);
|
||||
}
|
||||
|
||||
// Close the "currently recording" window on Finished so the next
|
||||
// test's events don't get mis-attributed. Keeping the pointer open
|
||||
// through the outcome subscribers is what lets a late-firing
|
||||
// `ConsideredRisky` overwrite an earlier `Passed`.
|
||||
$this->collector->finishTest();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user