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": {
|
"require": {
|
||||||
"php": "^8.3.0",
|
"php": "^8.3.0",
|
||||||
"brianium/paratest": "^7.20.0",
|
"brianium/paratest": "^7.20.0",
|
||||||
"nunomaduro/collision": "^8.9.3",
|
"nunomaduro/collision": "^8.9.4",
|
||||||
"nunomaduro/termwind": "^2.4.0",
|
"nunomaduro/termwind": "^2.4.0",
|
||||||
"pestphp/pest-plugin": "^4.0.0",
|
"pestphp/pest-plugin": "^4.0.0",
|
||||||
"pestphp/pest-plugin-arch": "^4.0.2",
|
"pestphp/pest-plugin-arch": "^4.0.2",
|
||||||
|
|||||||
@ -9,8 +9,8 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
|
|||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
use Pest\Plugins\Tia;
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Preset;
|
use Pest\Preset;
|
||||||
use Pest\Support\Container;
|
|
||||||
use Pest\Support\ChainableClosure;
|
use Pest\Support\ChainableClosure;
|
||||||
|
use Pest\Support\Container;
|
||||||
use Pest\Support\ExceptionTrace;
|
use Pest\Support\ExceptionTrace;
|
||||||
use Pest\Support\Reflection;
|
use Pest\Support\Reflection;
|
||||||
use Pest\Support\Shell;
|
use Pest\Support\Shell;
|
||||||
|
|||||||
@ -4,10 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins;
|
namespace Pest\Plugins;
|
||||||
|
|
||||||
|
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
|
||||||
use Pest\Contracts\Plugins\AddsOutput;
|
use Pest\Contracts\Plugins\AddsOutput;
|
||||||
use Pest\Contracts\Plugins\HandlesArguments;
|
use Pest\Contracts\Plugins\HandlesArguments;
|
||||||
use Pest\Contracts\Plugins\Terminable;
|
use Pest\Contracts\Plugins\Terminable;
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
|
||||||
use Pest\Plugins\Tia\BaselineSync;
|
use Pest\Plugins\Tia\BaselineSync;
|
||||||
use Pest\Plugins\Tia\ChangedFiles;
|
use Pest\Plugins\Tia\ChangedFiles;
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
@ -19,6 +19,7 @@ use Pest\Plugins\Tia\ResultCollector;
|
|||||||
use Pest\Plugins\Tia\WatchPatterns;
|
use Pest\Plugins\Tia\WatchPatterns;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@ -29,7 +30,7 @@ use Throwable;
|
|||||||
* -----
|
* -----
|
||||||
* - **Record** — no graph (or fingerprint / recording commit drifted). The
|
* - **Record** — no graph (or fingerprint / recording commit drifted). The
|
||||||
* full suite runs with PCOV / Xdebug capture per test; the resulting
|
* 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
|
* - **Replay** — graph valid. We diff the working tree against the recording
|
||||||
* commit, intersect changed files with graph edges, and run only the
|
* commit, intersect changed files with graph edges, and run only the
|
||||||
* affected tests. Newly-added tests unknown to the graph are always
|
* 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
|
* - **Worker, record**: boots through `bin/worker.php`, which re-runs
|
||||||
* `CallsHandleArguments`. We detect the worker context + recording flag,
|
* `CallsHandleArguments`. We detect the worker context + recording flag,
|
||||||
* activate the `Recorder`, and flush the partial graph on `terminate()`
|
* 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.
|
* - **Worker, replay**: nothing to do; args already narrowed.
|
||||||
*
|
*
|
||||||
* Guardrails
|
* Guardrails
|
||||||
@ -73,34 +74,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private const string REBUILD_OPTION = '--tia-rebuild';
|
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
|
* State keys under which TIA persists its blobs. Kept here as constants
|
||||||
* (rather than scattered strings) so the storage layout is visible in
|
* (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`
|
* Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage`
|
||||||
* run. Stored as bytes so the backend stays JSON/file-agnostic — the
|
* run. Stored as bytes so the backend stays JSON/file-agnostic — the
|
||||||
* merger un/serialises rather than `require`-ing a PHP file.
|
* 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
|
* Marker key dropped by `Tia` to tell `Support\Coverage` to apply the
|
||||||
* merge. Absent on plain `--coverage` runs so non-TIA usage keeps its
|
* merge. Absent on plain `--coverage` runs so non-TIA usage keeps its
|
||||||
* current (narrow) behaviour.
|
* 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.
|
* 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).
|
* 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';
|
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
|
||||||
|
|
||||||
@ -152,12 +153,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private array $cachedAssertionsByTestId = [];
|
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
|
* Holds the graph during replay so `beforeEach` can look up cached
|
||||||
* results without re-loading from disk on every test.
|
* results without re-loading from disk on every test.
|
||||||
@ -178,12 +173,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private array $affectedFiles = [];
|
private array $affectedFiles = [];
|
||||||
|
|
||||||
private static function workerEdgesKey(string $token): string
|
private function workerEdgesKey(string $token): string
|
||||||
{
|
{
|
||||||
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function workerResultsKey(string $token): string
|
private function workerResultsKey(string $token): string
|
||||||
{
|
{
|
||||||
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
|
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
|
public function getCachedResult(string $filename, string $testId): ?TestStatus
|
||||||
{
|
{
|
||||||
if ($this->replayGraph === null) {
|
if (! $this->replayGraph instanceof Graph) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,7 +271,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
// branch (falls back to main if branch is fresh).
|
// branch (falls back to main if branch is fresh).
|
||||||
$result = $this->replayGraph->getResult($this->branch, $testId);
|
$result = $this->replayGraph->getResult($this->branch, $testId);
|
||||||
|
|
||||||
if ($result !== null) {
|
if ($result instanceof TestStatus) {
|
||||||
$this->replayedCount++;
|
$this->replayedCount++;
|
||||||
// Cache the assertion count alongside the status so `Testable`
|
// Cache the assertion count alongside the status so `Testable`
|
||||||
// can emit the exact `addToAssertionCount()` at replay time
|
// 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';
|
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
|
||||||
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
||||||
|
|
||||||
// `--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);
|
$enabled = $this->hasArgument(self::OPTION, $arguments);
|
||||||
$forceRebuild = $this->hasArgument(self::REBUILD_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
|
// the graph lands with results on first write — otherwise the next
|
||||||
// run would load a graph with edges but empty results, miss the
|
// run would load a graph with edges but empty results, miss the
|
||||||
// cache for every test, and look pointlessly slow.
|
// 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();
|
$this->flushWorkerReplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -391,7 +376,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Parallel::isWorker()) {
|
if (Parallel::isWorker()) {
|
||||||
$this->flushWorkerPartial($projectRoot, $perTest);
|
$this->flushWorkerPartial($perTest);
|
||||||
$recorder->reset();
|
$recorder->reset();
|
||||||
$this->coverageCollector->reset();
|
$this->coverageCollector->reset();
|
||||||
|
|
||||||
@ -460,7 +445,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
if ($this->replayRan) {
|
if ($this->replayRan) {
|
||||||
$this->bumpRecordedSha();
|
$this->bumpRecordedSha();
|
||||||
$this->emitReplaySummary();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
|
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
|
||||||
@ -519,6 +503,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$finalised[$testFile] = array_keys($sourceSet);
|
$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->replaceEdges($finalised);
|
||||||
$graph->pruneMissingTests();
|
$graph->pruneMissingTests();
|
||||||
|
|
||||||
@ -543,6 +543,56 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $exitCode;
|
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
|
* @param array<int, string> $arguments
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
@ -563,11 +613,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot);
|
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot);
|
||||||
|
|
||||||
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
|
if ($graph instanceof Graph) {
|
||||||
$this->output->writeln(
|
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
||||||
' <fg=yellow>TIA</> environment fingerprint changed — graph will be rebuilt.',
|
|
||||||
);
|
|
||||||
$graph = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($graph instanceof Graph) {
|
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
|
// No local graph and not being forced to rebuild from scratch: try
|
||||||
// to pull a team-shared baseline so fresh checkouts (new devs, CI
|
// to pull a team-shared baseline so fresh checkouts (new devs, CI
|
||||||
// containers) don't pay the full record cost. If the pull succeeds
|
// containers) don't pay the full record cost. If the pull succeeds
|
||||||
// the graph is re-read and re-validated against the local env.
|
// the graph is re-read and reconciled against the local env.
|
||||||
if ($graph === null && ! $forceRebuild) {
|
if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot)) {
|
||||||
if ($this->baselineSync->fetchIfAvailable($projectRoot)) {
|
|
||||||
$graph = $this->loadGraph($projectRoot);
|
$graph = $this->loadGraph($projectRoot);
|
||||||
|
if ($graph instanceof Graph) {
|
||||||
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
|
$graph = $this->reconcileFingerprint($graph, $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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -615,14 +654,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
// report collapses to near-zero coverage. Fall back to recording
|
// report collapses to near-zero coverage. Fall back to recording
|
||||||
// (full suite) to seed the cache for next time.
|
// (full suite) to seed the cache for next time.
|
||||||
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
|
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
|
||||||
return $this->enterRecordMode($projectRoot, $arguments);
|
return $this->enterRecordMode($arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($graph instanceof Graph) {
|
if ($graph instanceof Graph) {
|
||||||
return $this->enterReplayMode($graph, $projectRoot, $arguments);
|
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);
|
$affected = $changed === [] ? [] : $graph->affected($changed);
|
||||||
|
|
||||||
$this->changedFileCount = count($changed);
|
|
||||||
|
|
||||||
$affectedSet = array_fill_keys($affected, true);
|
$affectedSet = array_fill_keys($affected, true);
|
||||||
|
|
||||||
$this->replayRan = true;
|
$this->replayRan = true;
|
||||||
$this->replayGraph = $graph;
|
$this->replayGraph = $graph;
|
||||||
$this->affectedFiles = $affectedSet;
|
$this->affectedFiles = $affectedSet;
|
||||||
|
|
||||||
|
$this->registerRecap();
|
||||||
|
|
||||||
if (! Parallel::isEnabled()) {
|
if (! Parallel::isEnabled()) {
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parallel: persist affected set so workers can install the filter.
|
// Parallel: persist affected set so workers can install the filter.
|
||||||
if (! $this->persistAffectedSet($projectRoot, $affected)) {
|
if (! $this->persistAffectedSet($affected)) {
|
||||||
$this->output->writeln(
|
$this->output->writeln(
|
||||||
' <fg=red>TIA</> failed to persist affected set — running full suite.',
|
' <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
|
// Clear stale partials from a previous interrupted run so the merge
|
||||||
// pass doesn't pick up results from an unrelated invocation.
|
// pass doesn't pick up results from an unrelated invocation.
|
||||||
$this->purgeWorkerPartials($projectRoot);
|
$this->purgeWorkerPartials();
|
||||||
|
|
||||||
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
|
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.
|
* @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);
|
$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
|
* @param array<int, string> $arguments
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
private function enterRecordMode(string $projectRoot, array $arguments): array
|
private function enterRecordMode(array $arguments): array
|
||||||
{
|
{
|
||||||
$recorder = $this->recorder;
|
$recorder = $this->recorder;
|
||||||
|
|
||||||
@ -810,7 +849,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
// recording. We only advertise the intent through a global.
|
// recording. We only advertise the intent through a global.
|
||||||
// Clean up any stale partial files from a previous interrupted
|
// Clean up any stale partial files from a previous interrupted
|
||||||
// run so the merge step doesn't confuse itself.
|
// run so the merge step doesn't confuse itself.
|
||||||
$this->purgeWorkerPartials($projectRoot);
|
$this->purgeWorkerPartials();
|
||||||
|
|
||||||
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
|
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
|
||||||
|
|
||||||
@ -865,7 +904,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
/**
|
/**
|
||||||
* @param array<string, array<int, string>> $perTest
|
* @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);
|
$json = json_encode($perTest, JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
@ -873,7 +912,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
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);
|
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function purgeWorkerPartials(string $projectRoot): void
|
private function purgeWorkerPartials(): void
|
||||||
{
|
{
|
||||||
foreach ($this->collectWorkerEdgesPartials() as $key) {
|
foreach ($this->collectWorkerEdgesPartials() as $key) {
|
||||||
$this->state->delete($key);
|
$this->state->delete($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->collectWorkerReplayPartials() as $key) {
|
foreach ($this->collectWorkerReplayPartials() as $key) {
|
||||||
$this->state->delete($key);
|
$this->state->delete($key);
|
||||||
}
|
}
|
||||||
@ -921,7 +959,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
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 */
|
/** @var mixed $result */
|
||||||
foreach ($decoded['results'] as $testId => $result) {
|
foreach ($decoded['results'] as $testId => $result) {
|
||||||
if (! is_string($testId) || ! is_array($result)) {
|
if (! is_string($testId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($result)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$normalised[$testId] = [
|
$normalised[$testId] = [
|
||||||
'status' => is_int($result['status'] ?? null) ? $result['status'] : 0,
|
'status' => is_int($result['status'] ?? null) ? $result['status'] : 0,
|
||||||
'message' => is_string($result['message'] ?? null) ? $result['message'] : '',
|
'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.
|
* git still reports them as modified.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Prints the post-run TIA summary. Runs after the test report so the
|
* Hooks a recap callback into Collision's `DefaultPrinter` so TIA's
|
||||||
* replayed count reflects what actually happened (cache hits counted
|
* counts ride along the "Tests: N passed (M assertions, ...)" line
|
||||||
* inside `getCachedResult`) rather than a graph-level estimate that
|
* instead of printing on their own block. Collision joins each
|
||||||
* ignores any CLI path filter the user passed in.
|
* 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
|
DefaultPrinter::addRecap(function (): string {
|
||||||
// by `getCachedResult()` — every test id that hits that method bumps
|
$fragments = [];
|
||||||
// exactly one of them. Summing the two gives the test-method total
|
|
||||||
// that lines up with Pest's "Tests: N" banner directly above.
|
if ($this->executedCount > 0) {
|
||||||
$this->output->writeln(sprintf(
|
$fragments[] = $this->executedCount.' affected';
|
||||||
' <fg=green>TIA</> %d changed file(s) → %d affected, %d replayed.',
|
}
|
||||||
$this->changedFileCount,
|
|
||||||
$this->executedCount,
|
if ($this->replayedCount > 0) {
|
||||||
$this->replayedCount,
|
$fragments[] = $this->replayedCount.' replayed';
|
||||||
));
|
}
|
||||||
|
|
||||||
|
return implode(', ', $fragments);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function bumpRecordedSha(): void
|
private function bumpRecordedSha(): void
|
||||||
|
|||||||
@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Composer\InstalledVersions;
|
||||||
use Pest\Plugins\Tia;
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Process\Process;
|
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
|
* contributors and fresh CI workspaces start in replay mode instead of
|
||||||
* paying the ~30s record cost.
|
* paying the ~30s record cost.
|
||||||
*
|
*
|
||||||
* The baseline lives as a GitHub Release with a fixed tag containing two
|
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
|
||||||
* assets — the graph JSON and the coverage cache. The repo is inferred
|
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
|
||||||
* from `.git/config`'s `origin` remote, so no per-project configuration
|
* suite under `--tia` and uploads the `.temp/tia/` directory as a named
|
||||||
* is required. Non-GitHub remotes silently opt out.
|
* 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:
|
* Why artifacts, not releases:
|
||||||
* 1. `gh release download` — uses the user's existing GitHub auth,
|
* - No tag is created → no `push` event cascade into CI workflows.
|
||||||
* works for private repos.
|
* - No release event → no deploy workflows tied to `release:published`.
|
||||||
* 2. Plain HTTPS — public-repo fallback when `gh` isn't installed.
|
* - 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
|
* Fingerprint validation happens back in `Tia::handleParent` after the
|
||||||
* blobs are written: a mismatched environment (different PHP version,
|
* blobs are written: a mismatched environment (different PHP version,
|
||||||
@ -32,17 +39,23 @@ use Symfony\Component\Process\Process;
|
|||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class BaselineSync
|
final readonly class BaselineSync
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Conventional tag the CI recipe publishes under. Not configurable for
|
* Conventional workflow filename teams publish from. Not configurable
|
||||||
* MVP — if teams outgrow the convention, a `PEST_TIA_BASELINE_TAG` env
|
* for MVP — teams that outgrow the default can set
|
||||||
* var is the likely escape hatch.
|
* `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.
|
* CI publisher and the sync consumer stay in lock-step.
|
||||||
*/
|
*/
|
||||||
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
||||||
@ -50,16 +63,15 @@ final class BaselineSync
|
|||||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly State $state,
|
private State $state,
|
||||||
private readonly OutputInterface $output,
|
private OutputInterface $output,
|
||||||
private readonly InputInterface $input,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts the full detect → prompt → download flow. Returns true when
|
* Detects the repo, fetches the latest baseline artifact, writes its
|
||||||
* the graph blob was pulled and written to state. Coverage is best-
|
* contents into the TIA state store. Returns true when the graph blob
|
||||||
* effort: its absence doesn't fail the sync, since plain `--tia` (no
|
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
|
||||||
* `--coverage`) works fine without it.
|
* never reads it.
|
||||||
*/
|
*/
|
||||||
public function fetchIfAvailable(string $projectRoot): bool
|
public function fetchIfAvailable(string $projectRoot): bool
|
||||||
{
|
{
|
||||||
@ -69,231 +81,165 @@ final class BaselineSync
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->confirm($repo)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->output->writeln(sprintf(
|
$this->output->writeln(sprintf(
|
||||||
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
||||||
$repo,
|
$repo,
|
||||||
));
|
));
|
||||||
|
|
||||||
$graphJson = $this->download($repo, self::GRAPH_ASSET);
|
$payload = $this->download($repo);
|
||||||
|
|
||||||
if ($graphJson === null) {
|
if ($payload === null) {
|
||||||
$this->output->writeln(
|
$this->emitPublishInstructions($repo);
|
||||||
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
|
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) {
|
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coverage cache is optional. The baseline is useful even without
|
if ($payload['coverage'] !== null) {
|
||||||
// it (plain `--tia` never needs it) so don't fail the whole sync
|
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->output->writeln(sprintf(
|
$this->output->writeln(sprintf(
|
||||||
' <fg=green>TIA</> baseline ready (%s).',
|
' <fg=green>TIA</> baseline ready (%s).',
|
||||||
$this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')),
|
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
|
||||||
));
|
));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publishes the *local* baseline to GitHub Releases under the
|
* Prints actionable instructions for publishing a first baseline when
|
||||||
* conventional tag, creating the release on first run or uploading
|
* the consumer-side fetch finds nothing.
|
||||||
* into the existing one otherwise.
|
|
||||||
*
|
*
|
||||||
* Uploading from a developer workstation is intentionally discouraged
|
* Behaviour splits on environment:
|
||||||
* — CI is the authoritative publisher because its environment is
|
* - **CI:** a single line. The current run is almost certainly *the*
|
||||||
* reproducible, its working tree is clean, and its result cache
|
* publisher (it's what this workflow does by definition), so
|
||||||
* isn't contaminated by local flakiness. The prompt here defaults to
|
* printing the whole recipe again is redundant and noisy.
|
||||||
* *No* to keep this an explicit, opt-in action.
|
* - **Local:** the full recipe, adapted to Laravel's pre-test steps
|
||||||
*
|
* (`.env.example` copy + `artisan key:generate`) when the framework
|
||||||
* Returns a CLI-style exit code so the caller can `exit()` on it.
|
* 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) {
|
return;
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$repo = $this->detectGitHubRepo($projectRoot);
|
$yaml = $this->isLaravel()
|
||||||
|
? $this->laravelWorkflowYaml()
|
||||||
|
: $this->genericWorkflowYaml();
|
||||||
|
|
||||||
if ($repo === null) {
|
$preamble = [
|
||||||
$this->output->writeln([
|
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
|
||||||
'',
|
'',
|
||||||
' <fg=red>TIA</> cannot infer a GitHub repo from <fg=gray>.git/config</>.',
|
' To share the baseline with your team, add this workflow to the repo:',
|
||||||
' Publishing is supported only for GitHub-hosted projects.',
|
|
||||||
'',
|
'',
|
||||||
]);
|
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->commandExists('gh')) {
|
|
||||||
$this->output->writeln([
|
|
||||||
'',
|
'',
|
||||||
' <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 = [
|
||||||
'',
|
'',
|
||||||
]);
|
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</>',
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->output->writeln([
|
|
||||||
'',
|
'',
|
||||||
' <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([...$preamble, ...$indentedYaml, ...$trailer]);
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads into the existing release if present, falls back to
|
* True when running inside a CI provider. Conservative list — only the
|
||||||
* creating the release with the assets attached on first run.
|
* three providers Pest formally supports / sees in the wild. `CI=true`
|
||||||
*
|
* alone is ambiguous (users set it locally too) so we require a
|
||||||
* @param array<int, string> $files
|
* 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'];
|
return getenv('GITHUB_ACTIONS') === 'true'
|
||||||
$upload = new Process($uploadArgs);
|
|| getenv('GITLAB_CI') === 'true'
|
||||||
$upload->setTimeout(300.0);
|
|| getenv('CIRCLECI') === 'true';
|
||||||
$upload->run(function (string $_, string $buffer): void {
|
|
||||||
$this->output->write($buffer);
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($upload->isSuccessful()) {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release likely doesn't exist yet — create it, attaching the files.
|
private function isLaravel(): bool
|
||||||
$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
|
|
||||||
{
|
{
|
||||||
if (! $this->isTerminal()) {
|
return class_exists(InstalledVersions::class)
|
||||||
return false;
|
&& InstalledVersions::isInstalled('laravel/framework');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->output->writeln(sprintf(
|
/**
|
||||||
' Publish to <fg=white>%s</> (tag <fg=white>%s</>)? <fg=gray>[y/N]</>',
|
* Laravel projects need a populated `.env` and a generated `APP_KEY`
|
||||||
$repo,
|
* before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException`
|
||||||
self::RELEASE_TAG,
|
* fires during `setUp`. Include the standard pre-test dance plus the
|
||||||
));
|
* extension set typical Laravel apps rely on.
|
||||||
|
*/
|
||||||
$handle = @fopen('php://stdin', 'r');
|
private function laravelWorkflowYaml(): string
|
||||||
|
{
|
||||||
if ($handle === false) {
|
return <<<'YAML'
|
||||||
return false;
|
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);
|
private function genericWorkflowYaml(): string
|
||||||
fclose($handle);
|
{
|
||||||
|
return <<<'YAML'
|
||||||
if ($line === false) {
|
name: TIA Baseline
|
||||||
return false;
|
on:
|
||||||
}
|
push: { branches: [main] }
|
||||||
|
schedule: [{ cron: '0 3 * * *' }]
|
||||||
// Unlike the fetch prompt, this one defaults to *No*. Empty input
|
workflow_dispatch:
|
||||||
// or anything other than an explicit "y"/"yes" cancels.
|
jobs:
|
||||||
$line = strtolower(trim($line));
|
baseline:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
return $line === 'y' || $line === 'yes';
|
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,
|
* Two-step fetch: find the latest successful run of the baseline
|
||||||
* piped input) returns false so scripted runs never hang waiting for
|
* workflow, then download the named artifact from it. Returns
|
||||||
* input.
|
* `['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
|
private function download(string $repo): ?array
|
||||||
{
|
|
||||||
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
|
|
||||||
{
|
{
|
||||||
if (! $this->commandExists('gh')) {
|
if (! $this->commandExists('gh')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$runId = $this->latestSuccessfulRunId($repo);
|
||||||
|
|
||||||
|
if ($runId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
|
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
|
||||||
|
|
||||||
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
|
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
|
||||||
@ -428,51 +311,67 @@ final class BaselineSync
|
|||||||
}
|
}
|
||||||
|
|
||||||
$process = new Process([
|
$process = new Process([
|
||||||
'gh', 'release', 'download', self::RELEASE_TAG,
|
'gh', 'run', 'download', $runId,
|
||||||
'-R', $repo,
|
'-R', $repo,
|
||||||
'-p', $asset,
|
'-n', self::ARTIFACT_NAME,
|
||||||
'-D', $tmpDir,
|
'-D', $tmpDir,
|
||||||
'--clobber',
|
|
||||||
]);
|
]);
|
||||||
$process->setTimeout(120.0);
|
$process->setTimeout(120.0);
|
||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
$payload = null;
|
if (! $process->isSuccessful()) {
|
||||||
|
$this->cleanup($tmpDir);
|
||||||
|
|
||||||
if ($process->isSuccessful()) {
|
return null;
|
||||||
$path = $tmpDir.DIRECTORY_SEPARATOR.$asset;
|
}
|
||||||
|
|
||||||
if (is_file($path)) {
|
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||||
$content = @file_get_contents($path);
|
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||||
$payload = $content === false ? null : $content;
|
|
||||||
}
|
$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);
|
$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(
|
$process = new Process([
|
||||||
'https://github.com/%s/releases/download/%s/%s',
|
'gh', 'run', 'list',
|
||||||
$repo,
|
'-R', $repo,
|
||||||
self::RELEASE_TAG,
|
'--workflow', self::WORKFLOW_FILE,
|
||||||
$asset,
|
'--status', 'success',
|
||||||
);
|
'--limit', '1',
|
||||||
|
'--json', 'databaseId',
|
||||||
$ctx = stream_context_create([
|
'--jq', '.[0].databaseId // empty',
|
||||||
'http' => [
|
|
||||||
'timeout' => 120,
|
|
||||||
'follow_location' => 1,
|
|
||||||
'ignore_errors' => false,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
$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
|
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
|
* TIA's own subdirectory under Pest's `.temp/`. Keeping every TIA blob
|
||||||
* caches share the same location as the rest of Pest's transient
|
* in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad
|
||||||
* state (PHPUnit result cache, coverage PHP dumps, etc.).
|
* 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
|
private function tempDir(): string
|
||||||
{
|
{
|
||||||
@ -42,6 +44,7 @@ final readonly class Bootstrapper implements BootstrapperContract
|
|||||||
.DIRECTORY_SEPARATOR.'..'
|
.DIRECTORY_SEPARATOR.'..'
|
||||||
.DIRECTORY_SEPARATOR.'..'
|
.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
|
* 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.
|
* exposing backend-specific glob semantics.
|
||||||
*
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
|
|||||||
@ -46,7 +46,7 @@ final class CoverageMerger
|
|||||||
{
|
{
|
||||||
$state = self::state();
|
$state = self::state();
|
||||||
|
|
||||||
if ($state === null || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ final class CoverageMerger
|
|||||||
// verbatim (as serialised bytes) for next time.
|
// verbatim (as serialised bytes) for next time.
|
||||||
$current = self::requireCoverage($reportPath);
|
$current = self::requireCoverage($reportPath);
|
||||||
|
|
||||||
if ($current !== null) {
|
if ($current instanceof CodeCoverage) {
|
||||||
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
|
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ final class CoverageMerger
|
|||||||
$cached = self::unserializeCoverage($cachedBytes);
|
$cached = self::unserializeCoverage($cachedBytes);
|
||||||
$current = self::requireCoverage($reportPath);
|
$current = self::requireCoverage($reportPath);
|
||||||
|
|
||||||
if ($cached === null || $current === null) {
|
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ final class CoverageMerger
|
|||||||
// can `require` it, and to the state cache for the next run.
|
// can `require` it, and to the state cache for the next run.
|
||||||
@file_put_contents(
|
@file_put_contents(
|
||||||
$reportPath,
|
$reportPath,
|
||||||
"<?php return unserialize(".var_export($serialised, true).");\n",
|
'<?php return unserialize('.var_export($serialised, true).");\n",
|
||||||
);
|
);
|
||||||
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised);
|
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised);
|
||||||
}
|
}
|
||||||
@ -108,10 +108,12 @@ final class CoverageMerger
|
|||||||
|
|
||||||
foreach ($lineCoverage as $file => $lines) {
|
foreach ($lineCoverage as $file => $lines) {
|
||||||
foreach ($lines as $line => $ids) {
|
foreach ($lines as $line => $ids) {
|
||||||
if ($ids === null || $ids === []) {
|
if ($ids === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($ids === []) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filtered = array_values(array_diff($ids, $currentIds));
|
$filtered = array_values(array_diff($ids, $currentIds));
|
||||||
|
|
||||||
if ($filtered !== $ids) {
|
if ($filtered !== $ids) {
|
||||||
@ -175,7 +177,6 @@ final class CoverageMerger
|
|||||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
/** @var mixed $value */
|
|
||||||
$value = @unserialize($bytes);
|
$value = @unserialize($bytes);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -17,14 +17,14 @@ use Pest\Plugins\Tia\Contracts\State;
|
|||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class FileState implements State
|
final readonly class FileState implements State
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Configured root. May not exist on disk yet; resolved + created on
|
* Configured root. May not exist on disk yet; resolved + created on
|
||||||
* the first write. Keeping the raw string lets the instance be built
|
* the first write. Keeping the raw string lets the instance be built
|
||||||
* before Pest's temp dir has been materialised.
|
* before Pest's temp dir has been materialised.
|
||||||
*/
|
*/
|
||||||
private readonly string $rootDir;
|
private string $rootDir;
|
||||||
|
|
||||||
public function __construct(string $rootDir)
|
public function __construct(string $rootDir)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,52 +5,161 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia;
|
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
|
* - **structural** — describes what the graph's *edges* were recorded
|
||||||
* what a test actually exercises, so the graph must be rebuilt in those cases.
|
* 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
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Fingerprint
|
final readonly class Fingerprint
|
||||||
{
|
{
|
||||||
// Bump this whenever the set of inputs or the hash algorithm changes, so
|
// Bump this whenever the set of inputs or the hash algorithm changes,
|
||||||
// older graphs are invalidated automatically.
|
// so older graphs are invalidated automatically.
|
||||||
private const int SCHEMA_VERSION = 2;
|
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
|
public static function compute(string $projectRoot): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'structural' => [
|
||||||
'schema' => self::SCHEMA_VERSION,
|
'schema' => self::SCHEMA_VERSION,
|
||||||
'php' => PHP_VERSION,
|
|
||||||
'pest' => self::readPestVersion($projectRoot),
|
|
||||||
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
|
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
|
||||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||||
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
|
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
|
||||||
// Pest's generated classes bake the code-generation logic in — if
|
// Pest's generated classes bake the code-generation logic
|
||||||
// TestCaseFactory changes (new attribute, different method
|
// in — if TestCaseFactory changes (new attribute, different
|
||||||
// signature, etc.) every previously-recorded edge is stale.
|
// method signature, etc.) every previously-recorded edge is
|
||||||
// Hashing the factory sources makes path-repo / dev-main installs
|
// stale. Hashing the factory sources makes path-repo /
|
||||||
// automatically rebuild their graphs when Pest itself is edited.
|
// dev-main installs automatically rebuild their graphs when
|
||||||
|
// Pest itself is edited.
|
||||||
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||||
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.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> $a
|
||||||
* @param array<string, mixed> $b
|
* @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);
|
$aStructural = self::structuralOnly($a);
|
||||||
ksort($b);
|
$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
|
private static function hashIfExists(string $path): ?string
|
||||||
@ -64,6 +173,25 @@ final readonly class Fingerprint
|
|||||||
return $hash === false ? null : $hash;
|
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
|
private static function readPestVersion(string $projectRoot): string
|
||||||
{
|
{
|
||||||
$installed = $projectRoot.'/vendor/composer/installed.json';
|
$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
|
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
|
public function fingerprint(): array
|
||||||
{
|
{
|
||||||
@ -282,9 +282,7 @@ final class Graph
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$value = $baseline['results'][$testId]['assertions'];
|
return $baseline['results'][$testId]['assertions'];
|
||||||
|
|
||||||
return is_int($value) ? $value : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
|
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
|
||||||
@ -323,6 +321,20 @@ final class Graph
|
|||||||
$this->baselines[$branch]['tree'] = $tree;
|
$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>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -60,6 +60,10 @@ final readonly class Laravel implements WatchDefault
|
|||||||
|
|
||||||
// Blade templates — compiled to cache, source file not executed.
|
// Blade templates — compiled to cache, source file not executed.
|
||||||
'resources/views/**/*.blade.php' => [$featurePath],
|
'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,
|
// Translations — JSON translations read via file_get_contents,
|
||||||
// PHP translations loaded via include (but during boot).
|
// 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.
|
// Livewire views live alongside Blade views or in a dedicated dir.
|
||||||
'resources/views/livewire/**/*.blade.php' => [$testPath],
|
'resources/views/livewire/**/*.blade.php' => [$testPath],
|
||||||
'resources/views/components/**/*.blade.php' => [$testPath],
|
'resources/views/components/**/*.blade.php' => [$testPath],
|
||||||
|
// Volt's second default mount — single-file components used as
|
||||||
|
// full-page routes. Missing this means editing a Volt page
|
||||||
|
// doesn't re-run its tests.
|
||||||
|
'resources/views/pages/**/*.blade.php' => [$testPath],
|
||||||
|
|
||||||
// Livewire JS interop / Alpine plugins.
|
// Livewire JS interop / Alpine plugins.
|
||||||
'resources/js/**/*.js' => [$testPath],
|
'resources/js/**/*.js' => [$testPath],
|
||||||
|
|||||||
@ -25,9 +25,14 @@ final readonly class Php implements WatchDefault
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
// Environment files — can change DB drivers, feature flags,
|
// 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' => [$testPath],
|
||||||
'.env.testing' => [$testPath],
|
'.env.testing' => [$testPath],
|
||||||
|
'.env.local' => [$testPath],
|
||||||
|
'.env.*.local' => [$testPath],
|
||||||
|
|
||||||
// Docker / CI — can affect integration test infrastructure.
|
// Docker / CI — can affect integration test infrastructure.
|
||||||
'docker-compose.yml' => [$testPath],
|
'docker-compose.yml' => [$testPath],
|
||||||
|
|||||||
@ -46,7 +46,11 @@ final readonly class Symfony implements WatchDefault
|
|||||||
'src/Kernel.php' => [$testPath],
|
'src/Kernel.php' => [$testPath],
|
||||||
|
|
||||||
// Migrations — run during setUp (before coverage window).
|
// Migrations — run during setUp (before coverage window).
|
||||||
|
// DoctrineMigrationsBundle's default is `migrations/` at the
|
||||||
|
// project root; many Symfony projects relocate to
|
||||||
|
// `src/Migrations/` — both covered.
|
||||||
'migrations/**/*.php' => [$testPath],
|
'migrations/**/*.php' => [$testPath],
|
||||||
|
'src/Migrations/**/*.php' => [$testPath],
|
||||||
|
|
||||||
// Twig templates — compiled, source not PHP-executed.
|
// Twig templates — compiled, source not PHP-executed.
|
||||||
'templates/**/*.html.twig' => [$testPath],
|
'templates/**/*.html.twig' => [$testPath],
|
||||||
|
|||||||
@ -16,9 +16,9 @@ use PHPUnit\Event\Test\FinishedSubscriber;
|
|||||||
*
|
*
|
||||||
* @internal
|
* @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
|
public function notify(Finished $event): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\ErroredSubscriber;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @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
|
public function notify(Errored $event): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\FailedSubscriber;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @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
|
public function notify(Failed $event): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @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
|
public function notify(MarkedIncomplete $event): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\PassedSubscriber;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @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
|
public function notify(Passed $event): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\ConsideredRiskySubscriber;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @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
|
public function notify(ConsideredRisky $event): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -11,9 +11,9 @@ use PHPUnit\Event\Test\SkippedSubscriber;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @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
|
public function notify(Skipped $event): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -20,9 +20,9 @@ use PHPUnit\Event\Test\PreparedSubscriber;
|
|||||||
*
|
*
|
||||||
* @internal
|
* @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
|
public function notify(Prepared $event): void
|
||||||
{
|
{
|
||||||
|
|||||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Support;
|
namespace Pest\Support;
|
||||||
|
|
||||||
use Pest\Exceptions\ShouldNotHappen;
|
use Pest\Exceptions\ShouldNotHappen;
|
||||||
|
use Pest\Plugins\Tia\CoverageMerger;
|
||||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||||
use SebastianBergmann\CodeCoverage\Node\Directory;
|
use SebastianBergmann\CodeCoverage\Node\Directory;
|
||||||
use SebastianBergmann\CodeCoverage\Node\File;
|
use SebastianBergmann\CodeCoverage\Node\File;
|
||||||
@ -92,7 +93,7 @@ final class Coverage
|
|||||||
// tests. Merge their fresh coverage slice into the cached full-run
|
// tests. Merge their fresh coverage slice into the cached full-run
|
||||||
// snapshot (stored by the previous `--tia --coverage` pass) so the
|
// snapshot (stored by the previous `--tia --coverage` pass) so the
|
||||||
// report reflects the entire suite, not just what re-ran.
|
// report reflects the entire suite, not just what re-ran.
|
||||||
\Pest\Plugins\Tia\CoverageMerger::applyIfMarked($reportPath);
|
CoverageMerger::applyIfMarked($reportPath);
|
||||||
|
|
||||||
/** @var CodeCoverage $codeCoverage */
|
/** @var CodeCoverage $codeCoverage */
|
||||||
$codeCoverage = require $reportPath;
|
$codeCoverage = require $reportPath;
|
||||||
|
|||||||
Reference in New Issue
Block a user