mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
8 Commits
a5915b16ab
...
856a370032
| Author | SHA1 | Date | |
|---|---|---|---|
| 856a370032 | |||
| e24882c486 | |||
| 51fc380789 | |||
| f6609f4039 | |||
| 2941f9821f | |||
| ed399af43e | |||
| 0d66dc4322 | |||
| 7e4280bf83 |
@ -19,7 +19,7 @@
|
||||
"require": {
|
||||
"php": "^8.3.0",
|
||||
"brianium/paratest": "^7.20.0",
|
||||
"nunomaduro/collision": "^8.9.3",
|
||||
"nunomaduro/collision": "^8.9.4",
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest-plugin": "^4.0.0",
|
||||
"pestphp/pest-plugin-arch": "^4.0.2",
|
||||
|
||||
@ -9,8 +9,8 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\ExceptionTrace;
|
||||
use Pest\Support\Reflection;
|
||||
use Pest\Support\Shell;
|
||||
|
||||
@ -4,10 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins;
|
||||
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
|
||||
use Pest\Contracts\Plugins\AddsOutput;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use Pest\Plugins\Tia\BaselineSync;
|
||||
use Pest\Plugins\Tia\ChangedFiles;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
@ -19,6 +19,7 @@ use Pest\Plugins\Tia\ResultCollector;
|
||||
use Pest\Plugins\Tia\WatchPatterns;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
|
||||
@ -29,7 +30,7 @@ use Throwable;
|
||||
* -----
|
||||
* - **Record** — no graph (or fingerprint / recording commit drifted). The
|
||||
* full suite runs with PCOV / Xdebug capture per test; the resulting
|
||||
* `test → [source_file, …]` edges land in `.temp/tia.json`.
|
||||
* `test → [source_file, …]` edges land in `.temp/tia/graph.json`.
|
||||
* - **Replay** — graph valid. We diff the working tree against the recording
|
||||
* commit, intersect changed files with graph edges, and run only the
|
||||
* affected tests. Newly-added tests unknown to the graph are always
|
||||
@ -52,7 +53,7 @@ use Throwable;
|
||||
* - **Worker, record**: boots through `bin/worker.php`, which re-runs
|
||||
* `CallsHandleArguments`. We detect the worker context + recording flag,
|
||||
* activate the `Recorder`, and flush the partial graph on `terminate()`
|
||||
* into `.temp/tia-worker-<TEST_TOKEN>.json`.
|
||||
* into `.temp/tia/worker-edges-<TEST_TOKEN>.json`.
|
||||
* - **Worker, replay**: nothing to do; args already narrowed.
|
||||
*
|
||||
* Guardrails
|
||||
@ -73,34 +74,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string REBUILD_OPTION = '--tia-rebuild';
|
||||
|
||||
private const string PUBLISH_OPTION = '--tia-publish';
|
||||
|
||||
/**
|
||||
* State keys under which TIA persists its blobs. Kept here as constants
|
||||
* (rather than scattered strings) so the storage layout is visible in
|
||||
* one place, and so `CoverageMerger` can reference the same keys.
|
||||
* one place, and so `CoverageMerger` can reference the same keys. All
|
||||
* files live under `.temp/tia/` — the `tia-` filename prefix is gone
|
||||
* because the directory already namespaces them.
|
||||
*/
|
||||
public const string KEY_GRAPH = 'tia.json';
|
||||
public const string KEY_GRAPH = 'graph.json';
|
||||
|
||||
public const string KEY_AFFECTED = 'tia-affected.json';
|
||||
public const string KEY_AFFECTED = 'affected.json';
|
||||
|
||||
private const string KEY_WORKER_EDGES_PREFIX = 'tia-worker-edges-';
|
||||
private const string KEY_WORKER_EDGES_PREFIX = 'worker-edges-';
|
||||
|
||||
private const string KEY_WORKER_RESULTS_PREFIX = 'tia-worker-results-';
|
||||
private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-';
|
||||
|
||||
/**
|
||||
* Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage`
|
||||
* run. Stored as bytes so the backend stays JSON/file-agnostic — the
|
||||
* merger un/serialises rather than `require`-ing a PHP file.
|
||||
*/
|
||||
public const string KEY_COVERAGE_CACHE = 'tia-coverage.bin';
|
||||
public const string KEY_COVERAGE_CACHE = 'coverage.bin';
|
||||
|
||||
/**
|
||||
* Marker key dropped by `Tia` to tell `Support\Coverage` to apply the
|
||||
* merge. Absent on plain `--coverage` runs so non-TIA usage keeps its
|
||||
* current (narrow) behaviour.
|
||||
*/
|
||||
public const string KEY_COVERAGE_MARKER = 'tia-coverage.marker';
|
||||
public const string KEY_COVERAGE_MARKER = 'coverage.marker';
|
||||
|
||||
/**
|
||||
* Global flag toggled by the parent process so workers know to record.
|
||||
@ -109,7 +110,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
/**
|
||||
* Global flag that tells workers to install the TIA filter (replay mode).
|
||||
* Workers read the affected set from `.temp/tia-affected.json`.
|
||||
* Workers read the affected set from `.temp/tia/affected.json`.
|
||||
*/
|
||||
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
|
||||
|
||||
@ -152,12 +153,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private array $cachedAssertionsByTestId = [];
|
||||
|
||||
/**
|
||||
* Captured at replay setup so the end-of-run summary can report the
|
||||
* scope of the changes that drove the run.
|
||||
*/
|
||||
private int $changedFileCount = 0;
|
||||
|
||||
/**
|
||||
* Holds the graph during replay so `beforeEach` can look up cached
|
||||
* results without re-loading from disk on every test.
|
||||
@ -178,12 +173,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private array $affectedFiles = [];
|
||||
|
||||
private static function workerEdgesKey(string $token): string
|
||||
private function workerEdgesKey(string $token): string
|
||||
{
|
||||
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private static function workerResultsKey(string $token): string
|
||||
private function workerResultsKey(string $token): string
|
||||
{
|
||||
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
|
||||
}
|
||||
@ -247,7 +242,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
public function getCachedResult(string $filename, string $testId): ?TestStatus
|
||||
{
|
||||
if ($this->replayGraph === null) {
|
||||
if (! $this->replayGraph instanceof Graph) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -276,7 +271,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
// branch (falls back to main if branch is fresh).
|
||||
$result = $this->replayGraph->getResult($this->branch, $testId);
|
||||
|
||||
if ($result !== null) {
|
||||
if ($result instanceof TestStatus) {
|
||||
$this->replayedCount++;
|
||||
// Cache the assertion count alongside the status so `Testable`
|
||||
// can emit the exact `addToAssertionCount()` at replay time
|
||||
@ -313,16 +308,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
|
||||
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
||||
|
||||
// `--tia-publish` is its own entry point: it neither records nor
|
||||
// replays, it just uploads whatever baseline is already on disk
|
||||
// and exits. Handled before the usual `--tia` gating so users can
|
||||
// publish without also triggering a suite run.
|
||||
if (! $isWorker && $this->hasArgument(self::PUBLISH_OPTION, $arguments)) {
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
|
||||
exit($this->baselineSync->publish($projectRoot));
|
||||
}
|
||||
|
||||
$enabled = $this->hasArgument(self::OPTION, $arguments);
|
||||
$forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments);
|
||||
|
||||
@ -366,7 +351,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
// the graph lands with results on first write — otherwise the next
|
||||
// run would load a graph with edges but empty results, miss the
|
||||
// cache for every test, and look pointlessly slow.
|
||||
if (Parallel::isWorker() && ($this->replayGraph !== null || $this->recordingActive)) {
|
||||
if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) {
|
||||
$this->flushWorkerReplay();
|
||||
}
|
||||
|
||||
@ -391,7 +376,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
if (Parallel::isWorker()) {
|
||||
$this->flushWorkerPartial($projectRoot, $perTest);
|
||||
$this->flushWorkerPartial($perTest);
|
||||
$recorder->reset();
|
||||
$this->coverageCollector->reset();
|
||||
|
||||
@ -460,7 +445,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
if ($this->replayRan) {
|
||||
$this->bumpRecordedSha();
|
||||
$this->emitReplaySummary();
|
||||
}
|
||||
|
||||
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
|
||||
@ -519,6 +503,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$finalised[$testFile] = array_keys($sourceSet);
|
||||
}
|
||||
|
||||
// Empty-edges guard: if every worker returned no edges it almost
|
||||
// always means the coverage driver wasn't loaded in the workers
|
||||
// (common footgun with custom PHP ini scan dirs, Herd profiles,
|
||||
// stripped CI runners). Writing the empty graph would silently
|
||||
// seed a broken baseline; fail loud instead.
|
||||
if ($finalised === []) {
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=white;bg=red> ERROR </> TIA recorded zero edges — coverage driver likely missing.',
|
||||
' Install / enable <fg=cyan>pcov</> or <fg=cyan>xdebug</> (mode: coverage) in the worker PHP and retry.',
|
||||
'',
|
||||
]);
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$graph->replaceEdges($finalised);
|
||||
$graph->pruneMissingTests();
|
||||
|
||||
@ -543,6 +543,56 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares a loaded graph's fingerprint to the current one and decides
|
||||
* how much of the graph is still usable.
|
||||
*
|
||||
* - **Structural drift** (composer.lock, Pest.php, factory codegen,
|
||||
* schema bump): edges themselves are potentially wrong → discard
|
||||
* the whole graph + coverage cache and return null. Caller falls
|
||||
* through to record mode.
|
||||
* - **Environmental drift** (PHP minor, extension set, Pest version):
|
||||
* edges describe the code correctly; only the cached per-test
|
||||
* results were captured against a different runtime and might not
|
||||
* reproduce. Drop `baselines[branch].results` + coverage cache,
|
||||
* bump the fingerprint to the current env, persist. Caller uses
|
||||
* the graph for edges; results refill naturally during this run's
|
||||
* replay (every test misses cache, runs normally, seeds results).
|
||||
* - **Match**: return the graph untouched.
|
||||
*
|
||||
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
|
||||
*/
|
||||
private function reconcileFingerprint(Graph $graph, array $current): ?Graph
|
||||
{
|
||||
$stored = $graph->fingerprint();
|
||||
|
||||
if (! Fingerprint::structuralMatches($stored, $current)) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> graph structure outdated — rebuilding.',
|
||||
);
|
||||
$this->state->delete(self::KEY_GRAPH);
|
||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$drift = Fingerprint::environmentalDrift($stored, $current);
|
||||
|
||||
if ($drift !== []) {
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> env differs from baseline (%s) — results dropped, edges reused.',
|
||||
implode(', ', $drift),
|
||||
));
|
||||
|
||||
$graph->clearResults($this->branch);
|
||||
$graph->setFingerprint($current);
|
||||
$this->saveGraph($graph);
|
||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
||||
}
|
||||
|
||||
return $graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
@ -563,11 +613,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot);
|
||||
|
||||
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> environment fingerprint changed — graph will be rebuilt.',
|
||||
);
|
||||
$graph = null;
|
||||
if ($graph instanceof Graph) {
|
||||
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
||||
}
|
||||
|
||||
if ($graph instanceof Graph) {
|
||||
@ -587,19 +634,11 @@ 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 re-validated against the local env.
|
||||
if ($graph === null && ! $forceRebuild) {
|
||||
if ($this->baselineSync->fetchIfAvailable($projectRoot)) {
|
||||
// the graph is re-read and reconciled against the local env.
|
||||
if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot)) {
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
|
||||
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> pulled baseline fingerprint mismatch — discarding.',
|
||||
);
|
||||
$this->state->delete(self::KEY_GRAPH);
|
||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
||||
$graph = null;
|
||||
}
|
||||
if ($graph instanceof Graph) {
|
||||
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
@ -615,14 +654,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
// report collapses to near-zero coverage. Fall back to recording
|
||||
// (full suite) to seed the cache for next time.
|
||||
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
|
||||
return $this->enterRecordMode($projectRoot, $arguments);
|
||||
return $this->enterRecordMode($arguments);
|
||||
}
|
||||
|
||||
if ($graph instanceof Graph) {
|
||||
return $this->enterReplayMode($graph, $projectRoot, $arguments);
|
||||
}
|
||||
|
||||
return $this->enterRecordMode($projectRoot, $arguments);
|
||||
return $this->enterRecordMode($arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -737,20 +776,20 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
$affected = $changed === [] ? [] : $graph->affected($changed);
|
||||
|
||||
$this->changedFileCount = count($changed);
|
||||
|
||||
$affectedSet = array_fill_keys($affected, true);
|
||||
|
||||
$this->replayRan = true;
|
||||
$this->replayGraph = $graph;
|
||||
$this->affectedFiles = $affectedSet;
|
||||
|
||||
$this->registerRecap();
|
||||
|
||||
if (! Parallel::isEnabled()) {
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
// Parallel: persist affected set so workers can install the filter.
|
||||
if (! $this->persistAffectedSet($projectRoot, $affected)) {
|
||||
if (! $this->persistAffectedSet($affected)) {
|
||||
$this->output->writeln(
|
||||
' <fg=red>TIA</> failed to persist affected set — running full suite.',
|
||||
);
|
||||
@ -760,7 +799,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
// Clear stale partials from a previous interrupted run so the merge
|
||||
// pass doesn't pick up results from an unrelated invocation.
|
||||
$this->purgeWorkerPartials($projectRoot);
|
||||
$this->purgeWorkerPartials();
|
||||
|
||||
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
|
||||
|
||||
@ -770,7 +809,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
/**
|
||||
* @param array<int, string> $affected Project-relative paths.
|
||||
*/
|
||||
private function persistAffectedSet(string $projectRoot, array $affected): bool
|
||||
private function persistAffectedSet(array $affected): bool
|
||||
{
|
||||
$json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES);
|
||||
|
||||
@ -785,7 +824,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function enterRecordMode(string $projectRoot, array $arguments): array
|
||||
private function enterRecordMode(array $arguments): array
|
||||
{
|
||||
$recorder = $this->recorder;
|
||||
|
||||
@ -810,7 +849,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
// recording. We only advertise the intent through a global.
|
||||
// Clean up any stale partial files from a previous interrupted
|
||||
// run so the merge step doesn't confuse itself.
|
||||
$this->purgeWorkerPartials($projectRoot);
|
||||
$this->purgeWorkerPartials();
|
||||
|
||||
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
|
||||
|
||||
@ -865,7 +904,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
/**
|
||||
* @param array<string, array<int, string>> $perTest
|
||||
*/
|
||||
private function flushWorkerPartial(string $projectRoot, array $perTest): void
|
||||
private function flushWorkerPartial(array $perTest): void
|
||||
{
|
||||
$json = json_encode($perTest, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
@ -873,7 +912,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$this->state->write(self::workerEdgesKey($this->workerToken()), $json);
|
||||
$this->state->write($this->workerEdgesKey($this->workerToken()), $json);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -884,12 +923,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
|
||||
}
|
||||
|
||||
private function purgeWorkerPartials(string $projectRoot): void
|
||||
private function purgeWorkerPartials(): void
|
||||
{
|
||||
foreach ($this->collectWorkerEdgesPartials() as $key) {
|
||||
$this->state->delete($key);
|
||||
}
|
||||
|
||||
foreach ($this->collectWorkerReplayPartials() as $key) {
|
||||
$this->state->delete($key);
|
||||
}
|
||||
@ -921,7 +959,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$this->state->write(self::workerResultsKey($this->workerToken()), $json);
|
||||
$this->state->write($this->workerResultsKey($this->workerToken()), $json);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -969,10 +1007,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
/** @var mixed $result */
|
||||
foreach ($decoded['results'] as $testId => $result) {
|
||||
if (! is_string($testId) || ! is_array($result)) {
|
||||
if (! is_string($testId)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalised[$testId] = [
|
||||
'status' => is_int($result['status'] ?? null) ? $result['status'] : 0,
|
||||
'message' => is_string($result['message'] ?? null) ? $result['message'] : '',
|
||||
@ -1057,23 +1097,28 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
* git still reports them as modified.
|
||||
*/
|
||||
/**
|
||||
* Prints the post-run TIA summary. Runs after the test report so the
|
||||
* replayed count reflects what actually happened (cache hits counted
|
||||
* inside `getCachedResult`) rather than a graph-level estimate that
|
||||
* ignores any CLI path filter the user passed in.
|
||||
* Hooks a recap callback into Collision's `DefaultPrinter` so TIA's
|
||||
* counts ride along the "Tests: N passed (M assertions, ...)" line
|
||||
* instead of printing on their own block. Collision joins each
|
||||
* callback's return value with a gray `, ` separator, so we return
|
||||
* a single fragment like `728 replayed via tia` (or nothing when
|
||||
* there's no replay activity to report).
|
||||
*/
|
||||
private function emitReplaySummary(): void
|
||||
private function registerRecap(): void
|
||||
{
|
||||
// `$executedCount` and `$replayedCount` are maintained in lockstep
|
||||
// by `getCachedResult()` — every test id that hits that method bumps
|
||||
// exactly one of them. Summing the two gives the test-method total
|
||||
// that lines up with Pest's "Tests: N" banner directly above.
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> %d changed file(s) → %d affected, %d replayed.',
|
||||
$this->changedFileCount,
|
||||
$this->executedCount,
|
||||
$this->replayedCount,
|
||||
));
|
||||
DefaultPrinter::addRecap(function (): string {
|
||||
$fragments = [];
|
||||
|
||||
if ($this->executedCount > 0) {
|
||||
$fragments[] = $this->executedCount.' affected';
|
||||
}
|
||||
|
||||
if ($this->replayedCount > 0) {
|
||||
$fragments[] = $this->replayedCount.' replayed';
|
||||
}
|
||||
|
||||
return implode(', ', $fragments);
|
||||
});
|
||||
}
|
||||
|
||||
private function bumpRecordedSha(): void
|
||||
|
||||
@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
@ -15,15 +15,22 @@ use Symfony\Component\Process\Process;
|
||||
* contributors and fresh CI workspaces start in replay mode instead of
|
||||
* paying the ~30s record cost.
|
||||
*
|
||||
* The baseline lives as a GitHub Release with a fixed tag containing two
|
||||
* assets — the graph JSON and the coverage cache. The repo is inferred
|
||||
* from `.git/config`'s `origin` remote, so no per-project configuration
|
||||
* is required. Non-GitHub remotes silently opt out.
|
||||
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
|
||||
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
|
||||
* suite under `--tia` and uploads the `.temp/tia/` directory as a named
|
||||
* artifact (`pest-tia-baseline`) containing `graph.json` +
|
||||
* `coverage.bin`. On dev
|
||||
* machines, this class finds the latest successful run of that workflow
|
||||
* and downloads the artifact via `gh`.
|
||||
*
|
||||
* Fetching is attempted in order:
|
||||
* 1. `gh release download` — uses the user's existing GitHub auth,
|
||||
* works for private repos.
|
||||
* 2. Plain HTTPS — public-repo fallback when `gh` isn't installed.
|
||||
* Why artifacts, not releases:
|
||||
* - No tag is created → no `push` event cascade into CI workflows.
|
||||
* - No release event → no deploy workflows tied to `release:published`.
|
||||
* - Retention is run-scoped and tunable (1-90 days) instead of clobbering
|
||||
* a single floating tag.
|
||||
* - Publishing is strictly CI-only: artifacts can't be produced from a
|
||||
* developer's laptop. This enforces the "CI is the authoritative
|
||||
* publisher" policy that local-publish paths would otherwise erode.
|
||||
*
|
||||
* Fingerprint validation happens back in `Tia::handleParent` after the
|
||||
* blobs are written: a mismatched environment (different PHP version,
|
||||
@ -32,17 +39,23 @@ use Symfony\Component\Process\Process;
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class BaselineSync
|
||||
final readonly class BaselineSync
|
||||
{
|
||||
/**
|
||||
* Conventional tag the CI recipe publishes under. Not configurable for
|
||||
* MVP — if teams outgrow the convention, a `PEST_TIA_BASELINE_TAG` env
|
||||
* var is the likely escape hatch.
|
||||
* Conventional workflow filename teams publish from. Not configurable
|
||||
* for MVP — teams that outgrow the default can set
|
||||
* `PEST_TIA_BASELINE_WORKFLOW` later.
|
||||
*/
|
||||
private const string RELEASE_TAG = 'pest-tia-baseline';
|
||||
private const string WORKFLOW_FILE = 'tia-baseline.yml';
|
||||
|
||||
/**
|
||||
* Asset filenames within the release — mirror the state keys so the
|
||||
* Artifact name the workflow uploads under. The artifact is a zip
|
||||
* containing `graph.json` (always) + `coverage.bin` (optional).
|
||||
*/
|
||||
private const string ARTIFACT_NAME = 'pest-tia-baseline';
|
||||
|
||||
/**
|
||||
* Asset filenames inside the artifact — mirror the state keys so the
|
||||
* CI publisher and the sync consumer stay in lock-step.
|
||||
*/
|
||||
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
||||
@ -50,16 +63,15 @@ final class BaselineSync
|
||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||
|
||||
public function __construct(
|
||||
private readonly State $state,
|
||||
private readonly OutputInterface $output,
|
||||
private readonly InputInterface $input,
|
||||
private State $state,
|
||||
private OutputInterface $output,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Attempts the full detect → prompt → download flow. Returns true when
|
||||
* the graph blob was pulled and written to state. Coverage is best-
|
||||
* effort: its absence doesn't fail the sync, since plain `--tia` (no
|
||||
* `--coverage`) works fine without it.
|
||||
* Detects the repo, fetches the latest baseline artifact, writes its
|
||||
* contents into the TIA state store. Returns true when the graph blob
|
||||
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
|
||||
* never reads it.
|
||||
*/
|
||||
public function fetchIfAvailable(string $projectRoot): bool
|
||||
{
|
||||
@ -69,231 +81,165 @@ final class BaselineSync
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->confirm($repo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
||||
$repo,
|
||||
));
|
||||
|
||||
$graphJson = $this->download($repo, self::GRAPH_ASSET);
|
||||
$payload = $this->download($repo);
|
||||
|
||||
if ($graphJson === null) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
|
||||
);
|
||||
if ($payload === null) {
|
||||
$this->emitPublishInstructions($repo);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) {
|
||||
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Coverage cache is optional. The baseline is useful even without
|
||||
// it (plain `--tia` never needs it) so don't fail the whole sync
|
||||
// just because this asset is missing or slow.
|
||||
$coverageBin = $this->download($repo, self::COVERAGE_ASSET);
|
||||
|
||||
if ($coverageBin !== null) {
|
||||
$this->state->write(Tia::KEY_COVERAGE_CACHE, $coverageBin);
|
||||
if ($payload['coverage'] !== null) {
|
||||
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> baseline ready (%s).',
|
||||
$this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')),
|
||||
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the *local* baseline to GitHub Releases under the
|
||||
* conventional tag, creating the release on first run or uploading
|
||||
* into the existing one otherwise.
|
||||
* Prints actionable instructions for publishing a first baseline when
|
||||
* the consumer-side fetch finds nothing.
|
||||
*
|
||||
* Uploading from a developer workstation is intentionally discouraged
|
||||
* — CI is the authoritative publisher because its environment is
|
||||
* reproducible, its working tree is clean, and its result cache
|
||||
* isn't contaminated by local flakiness. The prompt here defaults to
|
||||
* *No* to keep this an explicit, opt-in action.
|
||||
*
|
||||
* Returns a CLI-style exit code so the caller can `exit()` on it.
|
||||
* Behaviour splits on environment:
|
||||
* - **CI:** a single line. The current run is almost certainly *the*
|
||||
* publisher (it's what this workflow does by definition), so
|
||||
* printing the whole recipe again is redundant and noisy.
|
||||
* - **Local:** the full recipe, adapted to Laravel's pre-test steps
|
||||
* (`.env.example` copy + `artisan key:generate`) when the framework
|
||||
* is present. Generic PHP projects get a slimmer skeleton.
|
||||
*/
|
||||
public function publish(string $projectRoot): int
|
||||
private function emitPublishInstructions(string $repo): void
|
||||
{
|
||||
$graphBytes = $this->state->read(Tia::KEY_GRAPH);
|
||||
if ($this->isCi()) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> no baseline yet — this run will produce one.',
|
||||
);
|
||||
|
||||
if ($graphBytes === null) {
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=red>TIA</> no local baseline to publish.',
|
||||
' Run <fg=cyan>./vendor/bin/pest --tia</> first to record one, then retry.',
|
||||
'',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
return;
|
||||
}
|
||||
|
||||
$repo = $this->detectGitHubRepo($projectRoot);
|
||||
$yaml = $this->isLaravel()
|
||||
? $this->laravelWorkflowYaml()
|
||||
: $this->genericWorkflowYaml();
|
||||
|
||||
if ($repo === null) {
|
||||
$this->output->writeln([
|
||||
$preamble = [
|
||||
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
|
||||
'',
|
||||
' <fg=red>TIA</> cannot infer a GitHub repo from <fg=gray>.git/config</>.',
|
||||
' Publishing is supported only for GitHub-hosted projects.',
|
||||
' To share the baseline with your team, add this workflow to the repo:',
|
||||
'',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (! $this->commandExists('gh')) {
|
||||
$this->output->writeln([
|
||||
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
|
||||
'',
|
||||
' <fg=red>TIA</> publishing requires the <fg=cyan>gh</> CLI.',
|
||||
' Install: <fg=gray>https://cli.github.com</>',
|
||||
];
|
||||
|
||||
$indentedYaml = array_map(
|
||||
static fn (string $line): string => ' '.$line,
|
||||
explode("\n", $yaml),
|
||||
);
|
||||
|
||||
$trailer = [
|
||||
'',
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->output->writeln([
|
||||
sprintf(' Commit, push, then run once: <fg=cyan>gh workflow run tia-baseline.yml -R %s</>', $repo),
|
||||
' Details: <fg=gray>https://pestphp.com/docs/tia/ci</>',
|
||||
'',
|
||||
' <fg=black;bg=yellow> WARNING </> Publishing local baselines is discouraged.',
|
||||
'',
|
||||
' Local runs can bake flaky results or dirty working-tree state into the',
|
||||
' baseline, which your team then replays. CI-published baselines are safer.',
|
||||
' See <fg=gray>https://pestphp.com/docs/tia/ci</> for the recommended workflow.',
|
||||
'',
|
||||
]);
|
||||
];
|
||||
|
||||
if (! $this->confirmPublish($repo)) {
|
||||
$this->output->writeln(' <fg=yellow>TIA</> publish cancelled.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-publish-'.bin2hex(random_bytes(4));
|
||||
|
||||
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to create temp dir for upload.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||
|
||||
if (@file_put_contents($graphPath, $graphBytes) === false) {
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$filesToUpload = [$graphPath];
|
||||
|
||||
$coverageBytes = $this->state->read(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
if ($coverageBytes !== null) {
|
||||
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||
|
||||
if (@file_put_contents($coveragePath, $coverageBytes) !== false) {
|
||||
$filesToUpload[] = $coveragePath;
|
||||
}
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> publishing to <fg=white>%s</> (tag <fg=white>%s</>)…',
|
||||
$repo,
|
||||
self::RELEASE_TAG,
|
||||
));
|
||||
|
||||
$exitCode = $this->ghReleaseUploadOrCreate($repo, $filesToUpload);
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$this->output->writeln(' <fg=red>TIA</> <fg=cyan>gh release</> failed.');
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> baseline published (%s).',
|
||||
$this->formatSize(strlen($graphBytes) + ($coverageBytes === null ? 0 : strlen($coverageBytes))),
|
||||
));
|
||||
|
||||
return 0;
|
||||
$this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads into the existing release if present, falls back to
|
||||
* creating the release with the assets attached on first run.
|
||||
*
|
||||
* @param array<int, string> $files
|
||||
* True when running inside a CI provider. Conservative list — only the
|
||||
* three providers Pest formally supports / sees in the wild. `CI=true`
|
||||
* alone is ambiguous (users set it locally too) so we require a
|
||||
* provider-specific flag.
|
||||
*/
|
||||
private function ghReleaseUploadOrCreate(string $repo, array $files): int
|
||||
private function isCi(): bool
|
||||
{
|
||||
$uploadArgs = ['gh', 'release', 'upload', self::RELEASE_TAG, ...$files, '-R', $repo, '--clobber'];
|
||||
$upload = new Process($uploadArgs);
|
||||
$upload->setTimeout(300.0);
|
||||
$upload->run(function (string $_, string $buffer): void {
|
||||
$this->output->write($buffer);
|
||||
});
|
||||
|
||||
if ($upload->isSuccessful()) {
|
||||
return 0;
|
||||
return getenv('GITHUB_ACTIONS') === 'true'
|
||||
|| getenv('GITLAB_CI') === 'true'
|
||||
|| getenv('CIRCLECI') === 'true';
|
||||
}
|
||||
|
||||
// Release likely doesn't exist yet — create it, attaching the files.
|
||||
$createArgs = [
|
||||
'gh', 'release', 'create', self::RELEASE_TAG,
|
||||
...$files,
|
||||
'-R', $repo,
|
||||
'--title', 'Pest TIA baseline',
|
||||
'--notes', 'Machine-generated baseline for Pest TIA. Do not edit manually.',
|
||||
];
|
||||
$create = new Process($createArgs);
|
||||
$create->setTimeout(300.0);
|
||||
$create->run(function (string $_, string $buffer): void {
|
||||
$this->output->write($buffer);
|
||||
});
|
||||
|
||||
return $create->isSuccessful() ? 0 : 1;
|
||||
}
|
||||
|
||||
private function confirmPublish(string $repo): bool
|
||||
private function isLaravel(): bool
|
||||
{
|
||||
if (! $this->isTerminal()) {
|
||||
return false;
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('laravel/framework');
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' Publish to <fg=white>%s</> (tag <fg=white>%s</>)? <fg=gray>[y/N]</>',
|
||||
$repo,
|
||||
self::RELEASE_TAG,
|
||||
));
|
||||
|
||||
$handle = @fopen('php://stdin', 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
/**
|
||||
* Laravel projects need a populated `.env` and a generated `APP_KEY`
|
||||
* before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException`
|
||||
* fires during `setUp`. Include the standard pre-test dance plus the
|
||||
* extension set typical Laravel apps rely on.
|
||||
*/
|
||||
private function laravelWorkflowYaml(): string
|
||||
{
|
||||
return <<<'YAML'
|
||||
name: TIA Baseline
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
schedule: [{ cron: '0 3 * * *' }]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
baseline:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
coverage: xdebug
|
||||
extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl
|
||||
- run: cp .env.example .env
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: php artisan key:generate
|
||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pest-tia-baseline
|
||||
path: vendor/pestphp/pest/.temp/tia/
|
||||
retention-days: 30
|
||||
YAML;
|
||||
}
|
||||
|
||||
$line = fgets($handle);
|
||||
fclose($handle);
|
||||
|
||||
if ($line === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unlike the fetch prompt, this one defaults to *No*. Empty input
|
||||
// or anything other than an explicit "y"/"yes" cancels.
|
||||
$line = strtolower(trim($line));
|
||||
|
||||
return $line === 'y' || $line === 'yes';
|
||||
private function genericWorkflowYaml(): string
|
||||
{
|
||||
return <<<'YAML'
|
||||
name: TIA Baseline
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
schedule: [{ cron: '0 3 * * *' }]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
baseline:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with: { php-version: '8.4', coverage: xdebug }
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pest-tia-baseline
|
||||
path: vendor/pestphp/pest/.temp/tia/
|
||||
retention-days: 30
|
||||
YAML;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -338,89 +284,26 @@ final class BaselineSync
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot Y/n prompt. Defaults to Y. In non-interactive shells (CI,
|
||||
* piped input) returns false so scripted runs never hang waiting for
|
||||
* input.
|
||||
* Two-step fetch: find the latest successful run of the baseline
|
||||
* workflow, then download the named artifact from it. Returns
|
||||
* `['graph' => bytes, 'coverage' => bytes|null]` on success, or null
|
||||
* if `gh` is unavailable, the workflow hasn't run yet, the artifact
|
||||
* is missing, or any shell step fails.
|
||||
*
|
||||
* @return array{graph: string, coverage: ?string}|null
|
||||
*/
|
||||
private function confirm(string $repo): bool
|
||||
{
|
||||
if (! $this->isTerminal()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->output->writeln('');
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> no local cache — fetch baseline from <fg=white>%s</>? <fg=gray>[Y/n]</>',
|
||||
$repo,
|
||||
));
|
||||
|
||||
$handle = @fopen('php://stdin', 'r');
|
||||
|
||||
if ($handle === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$line = fgets($handle);
|
||||
fclose($handle);
|
||||
|
||||
if ($line === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$line = strtolower(trim($line));
|
||||
|
||||
return $line === '' || $line === 'y' || $line === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-TTY check for STDIN. Symfony's `isInteractive()` defaults to true
|
||||
* unless `--no-interaction` is explicitly passed, which would make
|
||||
* scripted invocations (CI, pipes, subshells) hang at a prompt nobody
|
||||
* sees. Combining both signals is the safe default.
|
||||
*/
|
||||
private function isTerminal(): bool
|
||||
{
|
||||
if (! $this->input->isInteractive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! defined('STDIN')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (function_exists('posix_isatty')) {
|
||||
return @posix_isatty(STDIN) === true;
|
||||
}
|
||||
|
||||
if (function_exists('stream_isatty')) {
|
||||
return @stream_isatty(STDIN) === true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries `gh` first (handles private repos + rate limiting via the
|
||||
* user's GitHub auth), falls through to public HTTPS. Returns the
|
||||
* raw asset bytes, or null on any failure.
|
||||
*/
|
||||
private function download(string $repo, string $asset): ?string
|
||||
{
|
||||
$viaGh = $this->downloadViaGh($repo, $asset);
|
||||
|
||||
if ($viaGh !== null) {
|
||||
return $viaGh;
|
||||
}
|
||||
|
||||
return $this->downloadViaHttps($repo, $asset);
|
||||
}
|
||||
|
||||
private function downloadViaGh(string $repo, string $asset): ?string
|
||||
private function download(string $repo): ?array
|
||||
{
|
||||
if (! $this->commandExists('gh')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$runId = $this->latestSuccessfulRunId($repo);
|
||||
|
||||
if ($runId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
|
||||
|
||||
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
|
||||
@ -428,51 +311,67 @@ final class BaselineSync
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
'gh', 'release', 'download', self::RELEASE_TAG,
|
||||
'gh', 'run', 'download', $runId,
|
||||
'-R', $repo,
|
||||
'-p', $asset,
|
||||
'-n', self::ARTIFACT_NAME,
|
||||
'-D', $tmpDir,
|
||||
'--clobber',
|
||||
]);
|
||||
$process->setTimeout(120.0);
|
||||
$process->run();
|
||||
|
||||
$payload = null;
|
||||
if (! $process->isSuccessful()) {
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
if ($process->isSuccessful()) {
|
||||
$path = $tmpDir.DIRECTORY_SEPARATOR.$asset;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_file($path)) {
|
||||
$content = @file_get_contents($path);
|
||||
$payload = $content === false ? null : $content;
|
||||
}
|
||||
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||
|
||||
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
|
||||
|
||||
if ($graph === false) {
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
|
||||
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return $payload;
|
||||
return [
|
||||
'graph' => $graph,
|
||||
'coverage' => $coverage === false ? null : $coverage,
|
||||
];
|
||||
}
|
||||
|
||||
private function downloadViaHttps(string $repo, string $asset): ?string
|
||||
/**
|
||||
* Queries GitHub for the most recent successful run of the baseline
|
||||
* workflow. `--jq '.[0].databaseId // empty'` coerces "no runs found"
|
||||
* into an empty string, which we map to null.
|
||||
*/
|
||||
private function latestSuccessfulRunId(string $repo): ?string
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://github.com/%s/releases/download/%s/%s',
|
||||
$repo,
|
||||
self::RELEASE_TAG,
|
||||
$asset,
|
||||
);
|
||||
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'timeout' => 120,
|
||||
'follow_location' => 1,
|
||||
'ignore_errors' => false,
|
||||
],
|
||||
$process = new Process([
|
||||
'gh', 'run', 'list',
|
||||
'-R', $repo,
|
||||
'--workflow', self::WORKFLOW_FILE,
|
||||
'--status', 'success',
|
||||
'--limit', '1',
|
||||
'--json', 'databaseId',
|
||||
'--jq', '.[0].databaseId // empty',
|
||||
]);
|
||||
$process->setTimeout(30.0);
|
||||
$process->run();
|
||||
|
||||
$content = @file_get_contents($url, false, $ctx);
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $content === false ? null : $content;
|
||||
$runId = trim($process->getOutput());
|
||||
|
||||
return $runId === '' ? null : $runId;
|
||||
}
|
||||
|
||||
private function commandExists(string $cmd): bool
|
||||
|
||||
@ -32,9 +32,11 @@ final readonly class Bootstrapper implements BootstrapperContract
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Pest's `.temp/` directory relative to this file so TIA's
|
||||
* caches share the same location as the rest of Pest's transient
|
||||
* state (PHPUnit result cache, coverage PHP dumps, etc.).
|
||||
* TIA's own subdirectory under Pest's `.temp/`. Keeping every TIA blob
|
||||
* in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad
|
||||
* alongside PHPUnit's unrelated files (coverage.php, test-results,
|
||||
* code-coverage/) and makes the CI artifact-upload path a single
|
||||
* directory instead of a list of individual files.
|
||||
*/
|
||||
private function tempDir(): string
|
||||
{
|
||||
@ -42,6 +44,7 @@ final readonly class Bootstrapper implements BootstrapperContract
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'.temp';
|
||||
.DIRECTORY_SEPARATOR.'.temp'
|
||||
.DIRECTORY_SEPARATOR.'tia';
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ interface State
|
||||
|
||||
/**
|
||||
* Returns every key whose name starts with `$prefix`. Used to collect
|
||||
* paratest worker partials (`tia-worker-<token>.json`, etc.) without
|
||||
* paratest worker partials (`worker-edges-<token>.json`, etc.) without
|
||||
* exposing backend-specific glob semantics.
|
||||
*
|
||||
* @return list<string>
|
||||
|
||||
@ -46,7 +46,7 @@ final class CoverageMerger
|
||||
{
|
||||
$state = self::state();
|
||||
|
||||
if ($state === null || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
||||
if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ final class CoverageMerger
|
||||
// verbatim (as serialised bytes) for next time.
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if ($current !== null) {
|
||||
if ($current instanceof CodeCoverage) {
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ final class CoverageMerger
|
||||
$cached = self::unserializeCoverage($cachedBytes);
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if ($cached === null || $current === null) {
|
||||
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ final class CoverageMerger
|
||||
// can `require` it, and to the state cache for the next run.
|
||||
@file_put_contents(
|
||||
$reportPath,
|
||||
"<?php return unserialize(".var_export($serialised, true).");\n",
|
||||
'<?php return unserialize('.var_export($serialised, true).");\n",
|
||||
);
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised);
|
||||
}
|
||||
@ -108,10 +108,12 @@ final class CoverageMerger
|
||||
|
||||
foreach ($lineCoverage as $file => $lines) {
|
||||
foreach ($lines as $line => $ids) {
|
||||
if ($ids === null || $ids === []) {
|
||||
if ($ids === null) {
|
||||
continue;
|
||||
}
|
||||
if ($ids === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered = array_values(array_diff($ids, $currentIds));
|
||||
|
||||
if ($filtered !== $ids) {
|
||||
@ -175,7 +177,6 @@ final class CoverageMerger
|
||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
||||
{
|
||||
try {
|
||||
/** @var mixed $value */
|
||||
$value = @unserialize($bytes);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
|
||||
@ -17,14 +17,14 @@ use Pest\Plugins\Tia\Contracts\State;
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class FileState implements State
|
||||
final readonly class FileState implements State
|
||||
{
|
||||
/**
|
||||
* Configured root. May not exist on disk yet; resolved + created on
|
||||
* the first write. Keeping the raw string lets the instance be built
|
||||
* before Pest's temp dir has been materialised.
|
||||
*/
|
||||
private readonly string $rootDir;
|
||||
private string $rootDir;
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
{
|
||||
|
||||
@ -5,52 +5,161 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Captures environmental inputs that, when changed, make the TIA graph stale.
|
||||
* Captures environmental inputs that, when changed, may make the TIA graph
|
||||
* or its recorded results stale. The fingerprint is split into two buckets:
|
||||
*
|
||||
* Any drift in PHP version, Composer lock, or Pest/PHPUnit config can change
|
||||
* what a test actually exercises, so the graph must be rebuilt in those cases.
|
||||
* - **structural** — describes what the graph's *edges* were recorded
|
||||
* against. If any of these drift (`composer.lock`, `tests/Pest.php`,
|
||||
* Pest's factory codegen, etc.) the edges themselves are potentially
|
||||
* wrong and the graph must rebuild from scratch.
|
||||
* - **environmental** — describes the *runtime* the results were captured
|
||||
* on (PHP minor, extension set, Pest version). Drift here means the
|
||||
* edges are still trustworthy, but the cached per-test results (pass/
|
||||
* fail/time) may not reproduce on this machine. Tia's handler drops the
|
||||
* branch's results + coverage cache and re-runs to freshen them, rather
|
||||
* than re-recording from scratch.
|
||||
*
|
||||
* Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
|
||||
* rebuilt on first load; the schema bump in the structural bucket takes
|
||||
* care of that automatically.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Fingerprint
|
||||
{
|
||||
// Bump this whenever the set of inputs or the hash algorithm changes, so
|
||||
// older graphs are invalidated automatically.
|
||||
private const int SCHEMA_VERSION = 2;
|
||||
// Bump this whenever the set of inputs or the hash algorithm changes,
|
||||
// so older graphs are invalidated automatically.
|
||||
private const int SCHEMA_VERSION = 4;
|
||||
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
* @return array{
|
||||
* structural: array<string, int|string|null>,
|
||||
* environmental: array<string, string|null>,
|
||||
* }
|
||||
*/
|
||||
public static function compute(string $projectRoot): array
|
||||
{
|
||||
return [
|
||||
'structural' => [
|
||||
'schema' => self::SCHEMA_VERSION,
|
||||
'php' => PHP_VERSION,
|
||||
'pest' => self::readPestVersion($projectRoot),
|
||||
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
|
||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
|
||||
// Pest's generated classes bake the code-generation logic in — if
|
||||
// TestCaseFactory changes (new attribute, different method
|
||||
// signature, etc.) every previously-recorded edge is stale.
|
||||
// Hashing the factory sources makes path-repo / dev-main installs
|
||||
// automatically rebuild their graphs when Pest itself is edited.
|
||||
// Pest's generated classes bake the code-generation logic
|
||||
// in — if TestCaseFactory changes (new attribute, different
|
||||
// method signature, etc.) every previously-recorded edge is
|
||||
// stale. Hashing the factory sources makes path-repo /
|
||||
// dev-main installs automatically rebuild their graphs when
|
||||
// Pest itself is edited.
|
||||
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||
],
|
||||
'environmental' => [
|
||||
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
|
||||
// almost never matches a dev's Herd/Homebrew install, and
|
||||
// the patch rarely changes anything test-visible.
|
||||
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
|
||||
'extensions' => self::extensionsFingerprint(),
|
||||
'pest' => self::readPestVersion($projectRoot),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the structural buckets match. Drift here means the edges
|
||||
* are potentially wrong; caller should discard the graph and rebuild.
|
||||
*
|
||||
* @param array<string, mixed> $a
|
||||
* @param array<string, mixed> $b
|
||||
*/
|
||||
public static function matches(array $a, array $b): bool
|
||||
public static function structuralMatches(array $a, array $b): bool
|
||||
{
|
||||
ksort($a);
|
||||
ksort($b);
|
||||
$aStructural = self::structuralOnly($a);
|
||||
$bStructural = self::structuralOnly($b);
|
||||
|
||||
return $a === $b;
|
||||
ksort($aStructural);
|
||||
ksort($bStructural);
|
||||
|
||||
return $aStructural === $bStructural;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of field names that drifted between the stored and
|
||||
* current environmental fingerprints. Empty list = no drift. Caller
|
||||
* uses this to print a human-readable warning and to decide whether
|
||||
* per-test results should be dropped (any drift → yes).
|
||||
*
|
||||
* @param array<string, mixed> $stored
|
||||
* @param array<string, mixed> $current
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function environmentalDrift(array $stored, array $current): array
|
||||
{
|
||||
$a = self::environmentalOnly($stored);
|
||||
$b = self::environmentalOnly($current);
|
||||
|
||||
$drifts = [];
|
||||
|
||||
foreach ($a as $key => $value) {
|
||||
if (($b[$key] ?? null) !== $value) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($b as $key => $value) {
|
||||
if (! array_key_exists($key, $a) && $value !== null) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($drifts));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function structuralOnly(array $fingerprint): array
|
||||
{
|
||||
return self::bucket($fingerprint, 'structural');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function environmentalOnly(array $fingerprint): array
|
||||
{
|
||||
return self::bucket($fingerprint, 'environmental');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `$fingerprint[$key]` as an `array<string, mixed>` if it exists
|
||||
* and is an array, otherwise empty. Legacy flat-shape fingerprints
|
||||
* (schema ≤ 3) return empty here, which makes `structuralMatches` fail
|
||||
* and the caller rebuild — the clean migration path.
|
||||
*
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function bucket(array $fingerprint, string $key): array
|
||||
{
|
||||
$raw = $fingerprint[$key] ?? null;
|
||||
|
||||
if (! is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalised = [];
|
||||
|
||||
foreach ($raw as $k => $v) {
|
||||
if (is_string($k)) {
|
||||
$normalised[$k] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
private static function hashIfExists(string $path): ?string
|
||||
@ -64,6 +173,25 @@ final readonly class Fingerprint
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic hash of the PHP extension set: `ext-name@version` pairs
|
||||
* sorted alphabetically and joined.
|
||||
*/
|
||||
private static function extensionsFingerprint(): string
|
||||
{
|
||||
$extensions = get_loaded_extensions();
|
||||
sort($extensions);
|
||||
|
||||
$parts = [];
|
||||
|
||||
foreach ($extensions as $name) {
|
||||
$version = phpversion($name);
|
||||
$parts[] = $name.'@'.($version === false ? '?' : $version);
|
||||
}
|
||||
|
||||
return hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function readPestVersion(string $projectRoot): string
|
||||
{
|
||||
$installed = $projectRoot.'/vendor/composer/installed.json';
|
||||
|
||||
@ -223,7 +223,7 @@ final class Graph
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|string|null> $fingerprint
|
||||
* @param array<string, mixed> $fingerprint
|
||||
*/
|
||||
public function setFingerprint(array $fingerprint): void
|
||||
{
|
||||
@ -231,7 +231,7 @@ final class Graph
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function fingerprint(): array
|
||||
{
|
||||
@ -282,9 +282,7 @@ final class Graph
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $baseline['results'][$testId]['assertions'];
|
||||
|
||||
return is_int($value) ? $value : null;
|
||||
return $baseline['results'][$testId]['assertions'];
|
||||
}
|
||||
|
||||
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
|
||||
@ -323,6 +321,20 @@ final class Graph
|
||||
$this->baselines[$branch]['tree'] = $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipes cached per-test results for the given branch. Edges and tree
|
||||
* snapshot stay intact — the graph still describes the code correctly,
|
||||
* only the "what happened last time" data is reset. Used on
|
||||
* environmental fingerprint drift: the edges were recorded elsewhere
|
||||
* (e.g. CI) so they're still valid, but the results aren't trustworthy
|
||||
* on this machine until the tests re-run here.
|
||||
*/
|
||||
public function clearResults(string $branch): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['results'] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@ -60,6 +60,10 @@ final readonly class Laravel implements WatchDefault
|
||||
|
||||
// Blade templates — compiled to cache, source file not executed.
|
||||
'resources/views/**/*.blade.php' => [$featurePath],
|
||||
// Email templates are nested under views/email or views/emails
|
||||
// by convention and power mailable tests that render markup.
|
||||
'resources/views/email/**/*.blade.php' => [$featurePath],
|
||||
'resources/views/emails/**/*.blade.php' => [$featurePath],
|
||||
|
||||
// Translations — JSON translations read via file_get_contents,
|
||||
// PHP translations loaded via include (but during boot).
|
||||
|
||||
@ -29,6 +29,10 @@ final readonly class Livewire implements WatchDefault
|
||||
// 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],
|
||||
|
||||
@ -25,9 +25,14 @@ final readonly class Php implements WatchDefault
|
||||
|
||||
return [
|
||||
// Environment files — can change DB drivers, feature flags,
|
||||
// queue connections, etc. Not PHP, not fingerprinted.
|
||||
// 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],
|
||||
|
||||
@ -46,7 +46,11 @@ final readonly class Symfony implements WatchDefault
|
||||
'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],
|
||||
|
||||
@ -16,9 +16,9 @@ use PHPUnit\Event\Test\FinishedSubscriber;
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
|
||||
final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(Finished $event): void
|
||||
{
|
||||
|
||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\ErroredSubscriber;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber
|
||||
final readonly class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(Errored $event): void
|
||||
{
|
||||
|
||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\FailedSubscriber;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaResultIsRecordedOnFailed implements FailedSubscriber
|
||||
final readonly class EnsureTiaResultIsRecordedOnFailed implements FailedSubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(Failed $event): void
|
||||
{
|
||||
|
||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaResultIsRecordedOnIncomplete implements MarkedIncompleteSubscriber
|
||||
final readonly class EnsureTiaResultIsRecordedOnIncomplete implements MarkedIncompleteSubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(MarkedIncomplete $event): void
|
||||
{
|
||||
|
||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\PassedSubscriber;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaResultIsRecordedOnPassed implements PassedSubscriber
|
||||
final readonly class EnsureTiaResultIsRecordedOnPassed implements PassedSubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(Passed $event): void
|
||||
{
|
||||
|
||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\ConsideredRiskySubscriber;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaResultIsRecordedOnRisky implements ConsideredRiskySubscriber
|
||||
final readonly class EnsureTiaResultIsRecordedOnRisky implements ConsideredRiskySubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(ConsideredRisky $event): void
|
||||
{
|
||||
|
||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\SkippedSubscriber;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaResultIsRecordedOnSkipped implements SkippedSubscriber
|
||||
final readonly class EnsureTiaResultIsRecordedOnSkipped implements SkippedSubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
|
||||
@ -20,9 +20,9 @@ use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaResultsAreCollected implements PreparedSubscriber
|
||||
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(Prepared $event): void
|
||||
{
|
||||
|
||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\Support;
|
||||
|
||||
use Pest\Exceptions\ShouldNotHappen;
|
||||
use Pest\Plugins\Tia\CoverageMerger;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Node\Directory;
|
||||
use SebastianBergmann\CodeCoverage\Node\File;
|
||||
@ -92,7 +93,7 @@ final class Coverage
|
||||
// tests. Merge their fresh coverage slice into the cached full-run
|
||||
// snapshot (stored by the previous `--tia --coverage` pass) so the
|
||||
// report reflects the entire suite, not just what re-ran.
|
||||
\Pest\Plugins\Tia\CoverageMerger::applyIfMarked($reportPath);
|
||||
CoverageMerger::applyIfMarked($reportPath);
|
||||
|
||||
/** @var CodeCoverage $codeCoverage */
|
||||
$codeCoverage = require $reportPath;
|
||||
|
||||
Reference in New Issue
Block a user