mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
5 Commits
0d99c33c4e
...
a5915b16ab
| Author | SHA1 | Date | |
|---|---|---|---|
| a5915b16ab | |||
| 1476b529a1 | |||
| 2892341c28 | |||
| 59e781e77b | |||
| 55a3394f8c |
@ -249,6 +249,17 @@ trait Testable
|
||||
return;
|
||||
}
|
||||
|
||||
// Risky tests have no public PHPUnit hook to replay as-risky.
|
||||
// Best available: short-circuit as a pass so the test doesn't
|
||||
// misreport as a failure. Aggregate risky totals won't
|
||||
// survive replay — accepted trade-off until PHPUnit grows a
|
||||
// programmatic risky-marker API.
|
||||
if ($cached->isRisky()) {
|
||||
$this->__cachedPass = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-success: throw the matching PHPUnit exception. Runner
|
||||
// catches it and marks the test with the correct status so
|
||||
// skips, failures, incompletes and todos appear in output
|
||||
@ -371,7 +382,14 @@ trait Testable
|
||||
private function __runTest(Closure $closure, ...$args): mixed
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
$this->addToAssertionCount(1);
|
||||
// Feed the exact assertion count captured during the recorded
|
||||
// run so Pest's "Tests: N passed (M assertions)" banner stays
|
||||
// accurate on replay instead of collapsing to 1-per-test.
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name());
|
||||
|
||||
$this->addToAssertionCount($assertions > 0 ? $assertions : 1);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@ use Pest\Plugins\Actions\CallsBoot;
|
||||
use Pest\Plugins\Actions\CallsHandleArguments;
|
||||
use Pest\Plugins\Actions\CallsHandleOriginalArguments;
|
||||
use Pest\Plugins\Actions\CallsTerminable;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\Reflection;
|
||||
use Pest\Support\View;
|
||||
@ -37,6 +36,7 @@ final readonly class Kernel
|
||||
*/
|
||||
private const array BOOTSTRAPPERS = [
|
||||
Bootstrappers\BootOverrides::class,
|
||||
Plugins\Tia\Bootstrapper::class,
|
||||
Bootstrappers\BootSubscribers::class,
|
||||
Bootstrappers\BootFiles::class,
|
||||
Bootstrappers\BootView::class,
|
||||
@ -65,11 +65,7 @@ final readonly class Kernel
|
||||
->add(TestSuite::class, $testSuite)
|
||||
->add(InputInterface::class, $input)
|
||||
->add(OutputInterface::class, $output)
|
||||
->add(Container::class, $container)
|
||||
->add(Tia\Recorder::class, new Tia\Recorder)
|
||||
->add(Tia\CoverageCollector::class, new Tia\CoverageCollector)
|
||||
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
|
||||
->add(Tia\ResultCollector::class, new Tia\ResultCollector);
|
||||
->add(Container::class, $container);
|
||||
|
||||
$kernel = new self(
|
||||
new Application,
|
||||
|
||||
@ -8,7 +8,9 @@ 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;
|
||||
use Pest\Plugins\Tia\CoverageCollector;
|
||||
use Pest\Plugins\Tia\Fingerprint;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
@ -71,38 +73,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string REBUILD_OPTION = '--tia-rebuild';
|
||||
|
||||
private const string PUBLISH_OPTION = '--tia-publish';
|
||||
|
||||
/**
|
||||
* TIA cache lives inside Pest's `.temp/` directory (same location as
|
||||
* PHPUnit's result cache). This directory is gitignored by default in
|
||||
* Pest's own `.gitignore`, so the graph is never committed.
|
||||
* 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.
|
||||
*/
|
||||
private const string TEMP_DIR = __DIR__
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'.temp';
|
||||
public const string KEY_GRAPH = 'tia.json';
|
||||
|
||||
private const string CACHE_FILE = 'tia.json';
|
||||
public const string KEY_AFFECTED = 'tia-affected.json';
|
||||
|
||||
private const string AFFECTED_FILE = 'tia-affected.json';
|
||||
private const string KEY_WORKER_EDGES_PREFIX = 'tia-worker-edges-';
|
||||
|
||||
private const string KEY_WORKER_RESULTS_PREFIX = 'tia-worker-results-';
|
||||
|
||||
/**
|
||||
* Cache file holding PHPUnit's `CodeCoverage` object from the last
|
||||
* `--tia --coverage` run. When the next run replays most tests from
|
||||
* the TIA graph, only the affected tests produce fresh coverage; the
|
||||
* rest is merged in from this cache so the report stays complete.
|
||||
* 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.
|
||||
*/
|
||||
private const string COVERAGE_CACHE_FILE = 'tia-coverage.php';
|
||||
public const string KEY_COVERAGE_CACHE = 'tia-coverage.bin';
|
||||
|
||||
/**
|
||||
* Marker file 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
|
||||
* current (narrow) behaviour.
|
||||
*/
|
||||
private const string COVERAGE_MARKER_FILE = 'tia-coverage.marker';
|
||||
|
||||
private const string WORKER_PREFIX = 'tia-worker-';
|
||||
|
||||
private const string WORKER_RESULTS_PREFIX = 'tia-worker-results-';
|
||||
public const string KEY_COVERAGE_MARKER = 'tia-coverage.marker';
|
||||
|
||||
/**
|
||||
* Global flag toggled by the parent process so workers know to record.
|
||||
@ -135,19 +133,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private int $replayedCount = 0;
|
||||
|
||||
/**
|
||||
* Counter-part of `$replayedCount`: every time `getCachedResult()`
|
||||
* decides the test must execute (affected, unknown, or no cached
|
||||
* result), we bump this. Together the two counters let the summary
|
||||
* show "affected + replayed" in units of test methods, not test
|
||||
* files, matching the "Tests: N" total Pest prints above.
|
||||
*/
|
||||
private int $executedCount = 0;
|
||||
|
||||
/**
|
||||
* Cached assertion count per test id for the current replay run. Keyed
|
||||
* by `ClassName::methodName`; populated when `getCachedResult()` hits
|
||||
* cache and drained by `Testable::__runTest()` on the short-circuit
|
||||
* path so the emitted count matches the recorded run.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* Captured at replay setup — number of tests the graph flagged as
|
||||
* affected (i.e. should re-execute). May overshoot the actually-
|
||||
* executed count when the user narrows with a path filter.
|
||||
*/
|
||||
private int $affectedTestCount = 0;
|
||||
|
||||
/**
|
||||
* Holds the graph during replay so `beforeEach` can look up cached
|
||||
* results without re-loading from disk on every test.
|
||||
@ -168,57 +178,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private array $affectedFiles = [];
|
||||
|
||||
private static function tempDir(): string
|
||||
private static function workerEdgesKey(string $token): string
|
||||
{
|
||||
$dir = (string) realpath(self::TEMP_DIR);
|
||||
|
||||
if ($dir === '' || $dir === '.') {
|
||||
// .temp doesn't exist yet — create it.
|
||||
@mkdir(self::TEMP_DIR, 0755, true);
|
||||
$dir = (string) realpath(self::TEMP_DIR);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private static function cachePath(): string
|
||||
private static function workerResultsKey(string $token): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private static function affectedPath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::AFFECTED_FILE;
|
||||
}
|
||||
|
||||
private static function workerPath(string $token): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private static function workerGlob(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.'*.json';
|
||||
}
|
||||
|
||||
private static function workerResultsPath(string $token): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private static function workerResultsGlob(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.'*.json';
|
||||
}
|
||||
|
||||
public static function coverageCachePath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_CACHE_FILE;
|
||||
}
|
||||
|
||||
public static function coverageMarkerPath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_MARKER_FILE;
|
||||
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -242,8 +209,38 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private readonly Recorder $recorder,
|
||||
private readonly CoverageCollector $coverageCollector,
|
||||
private readonly WatchPatterns $watchPatterns,
|
||||
private readonly State $state,
|
||||
private readonly BaselineSync $baselineSync,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: load + decode the graph, or return `null` if no
|
||||
* graph has been stored. Any call that needs to mutate + re-save the
|
||||
* graph also goes through `saveGraph()` to keep bytes flowing through
|
||||
* the `State` abstraction rather than filesystem paths.
|
||||
*/
|
||||
private function loadGraph(string $projectRoot): ?Graph
|
||||
{
|
||||
$json = $this->state->read(self::KEY_GRAPH);
|
||||
|
||||
if ($json === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Graph::decode($json, $projectRoot);
|
||||
}
|
||||
|
||||
private function saveGraph(Graph $graph): bool
|
||||
{
|
||||
$json = $graph->encode();
|
||||
|
||||
if ($json === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->state->write(self::KEY_GRAPH, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached result for the given test, or `null` if the test
|
||||
* must run (affected, unknown, or no replay mode active).
|
||||
@ -263,11 +260,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
// Affected files must re-execute.
|
||||
if ($rel !== null && isset($this->affectedFiles[$rel])) {
|
||||
$this->executedCount++;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unknown files (not in graph) must execute — they're new.
|
||||
if ($rel === null || ! $this->replayGraph->knowsTest($rel)) {
|
||||
$this->executedCount++;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -277,11 +278,32 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
if ($result !== null) {
|
||||
$this->replayedCount++;
|
||||
// Cache the assertion count alongside the status so `Testable`
|
||||
// can emit the exact `addToAssertionCount()` at replay time
|
||||
// without hitting the graph twice per test.
|
||||
$assertions = $this->replayGraph->getAssertions($this->branch, $testId);
|
||||
$this->cachedAssertionsByTestId[$testId] = $assertions ?? 0;
|
||||
} else {
|
||||
// Graph knows the test file but has no stored result for this
|
||||
// specific test id (new test, or first time seeing this method).
|
||||
// It must execute.
|
||||
$this->executedCount++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact assertion count captured for the given test during its last
|
||||
* recorded run. Returns `0` if unknown (new test, or old graph entry
|
||||
* pre-dating assertion-count tracking). `Testable::__runTest` reads
|
||||
* this to feed `addToAssertionCount()` instead of defaulting to 1.
|
||||
*/
|
||||
public function getCachedAssertions(string $testId): int
|
||||
{
|
||||
return $this->cachedAssertionsByTestId[$testId] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@ -291,6 +313,16 @@ 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);
|
||||
|
||||
@ -328,11 +360,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
// Worker in replay mode: flush the ResultCollector + replay counter
|
||||
// into a partial so the parent can merge them into the graph after
|
||||
// paratest returns. Parent's own ResultCollector is empty in parallel
|
||||
// runs because workers — not the parent — execute the tests.
|
||||
if (Parallel::isWorker() && $this->replayGraph !== null) {
|
||||
// Flush the ResultCollector + replay counter from workers into a
|
||||
// partial so the parent can merge them. Needed during replay so the
|
||||
// summary is accurate, and also during the initial record run so
|
||||
// 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)) {
|
||||
$this->flushWorkerReplay();
|
||||
}
|
||||
|
||||
@ -365,25 +399,33 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
// Non-parallel record path: straight into the main cache.
|
||||
$cachePath = self::cachePath();
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$currentSha = $changedFiles->currentSha();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
|
||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||
// Snapshot whatever is currently dirty in the working tree. Without
|
||||
// this, the very first `--tia` replay would see those same files
|
||||
// via `since()` and report them as "changed" — even though they're
|
||||
// identical to what we just recorded against.
|
||||
$graph->setLastRunTree(
|
||||
$this->branch,
|
||||
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
||||
);
|
||||
$graph->replaceEdges($perTest);
|
||||
$graph->pruneMissingTests();
|
||||
|
||||
if (! $graph->save($cachePath)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath);
|
||||
if (! $this->saveGraph($graph)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
|
||||
$recorder->reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files) at %s',
|
||||
' <fg=green>TIA</> graph recorded (%d test files).',
|
||||
count($perTest),
|
||||
self::CACHE_FILE,
|
||||
));
|
||||
|
||||
$recorder->reset();
|
||||
@ -406,45 +448,53 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
// since the original recording. Without this, re-running `--tia`
|
||||
// twice in a row would re-execute the same affected tests both
|
||||
// times even though nothing new changed.
|
||||
if ($this->replayRan) {
|
||||
// In parallel runs the workers executed the tests, so their
|
||||
// ResultCollector + replay counter live in other processes. Pull
|
||||
// those partials in before both the summary and the graph
|
||||
// snapshot so the parent state reflects the whole run.
|
||||
if (Parallel::isEnabled()) {
|
||||
$this->mergeWorkerReplayPartials();
|
||||
}
|
||||
// In parallel runs the workers executed the tests, so their
|
||||
// ResultCollector + replay counter live in other processes. Pull
|
||||
// those partials in first — both replay and record paths need them:
|
||||
// replay to make the summary accurate, record so the initial graph
|
||||
// lands with results instead of a second "warm-up" run being needed
|
||||
// before replay is actually fast.
|
||||
if (Parallel::isEnabled()) {
|
||||
$this->mergeWorkerReplayPartials();
|
||||
}
|
||||
|
||||
if ($this->replayRan) {
|
||||
$this->bumpRecordedSha();
|
||||
$this->emitReplaySummary();
|
||||
}
|
||||
|
||||
// Snapshot per-test results (status + message) from PHPUnit's result
|
||||
// cache into our graph so future replay runs can faithfully reproduce
|
||||
// pass/fail/skip/todo/incomplete for unaffected tests.
|
||||
$this->snapshotTestResults();
|
||||
|
||||
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
|
||||
// Series path: graph was already written by `terminate()` (or
|
||||
// nothing to record). Snapshot results now so they ride along.
|
||||
$this->snapshotTestResults();
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$partials = $this->collectWorkerPartials($projectRoot);
|
||||
$partialKeys = $this->collectWorkerEdgesPartials();
|
||||
|
||||
if ($partials === []) {
|
||||
if ($partialKeys === []) {
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$currentSha = $changedFiles->currentSha();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
|
||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||
// Snapshot any currently-dirty files so the first replay run
|
||||
// doesn't mis-report them as changed. See the series record path.
|
||||
$graph->setLastRunTree(
|
||||
$this->branch,
|
||||
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
||||
);
|
||||
|
||||
$merged = [];
|
||||
|
||||
foreach ($partials as $partialPath) {
|
||||
$data = $this->readPartial($partialPath);
|
||||
foreach ($partialKeys as $key) {
|
||||
$data = $this->readPartial($key);
|
||||
|
||||
if ($data === null) {
|
||||
continue;
|
||||
@ -460,7 +510,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
}
|
||||
|
||||
@unlink($partialPath);
|
||||
$this->state->delete($key);
|
||||
}
|
||||
|
||||
$finalised = [];
|
||||
@ -472,19 +522,24 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$graph->replaceEdges($finalised);
|
||||
$graph->pruneMissingTests();
|
||||
|
||||
if (! $graph->save($cachePath)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath);
|
||||
if (! $this->saveGraph($graph)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials) at %s',
|
||||
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials).',
|
||||
count($finalised),
|
||||
count($partials),
|
||||
self::CACHE_FILE,
|
||||
count($partialKeys),
|
||||
));
|
||||
|
||||
// Persist per-test results (merged from worker partials above) into
|
||||
// the freshly-written graph. Without this the graph would ship with
|
||||
// edges but no results, and the very next `--tia` run would miss
|
||||
// cache for every test even though nothing changed.
|
||||
$this->snapshotTestResults();
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
@ -504,10 +559,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
// the implicit branch identity.
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
$fingerprint = Fingerprint::compute($projectRoot);
|
||||
|
||||
$graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath);
|
||||
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot);
|
||||
|
||||
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
|
||||
$this->output->writeln(
|
||||
@ -530,11 +584,38 @@ 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)) {
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the marker so `Support\Coverage::report()` knows to merge the
|
||||
// current (narrow) coverage with the cached full-run snapshot. Plain
|
||||
// `--coverage` runs don't drop it, so their behaviour is untouched.
|
||||
if ($this->piggybackCoverage) {
|
||||
@file_put_contents(self::coverageMarkerPath(), '');
|
||||
$this->state->write(self::KEY_COVERAGE_MARKER, '');
|
||||
}
|
||||
|
||||
// First `--tia --coverage` run has nothing to merge against: if we
|
||||
// replay, the coverage driver sees only the affected tests and the
|
||||
// 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);
|
||||
}
|
||||
|
||||
if ($graph instanceof Graph) {
|
||||
@ -599,18 +680,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function installWorkerReplay(string $projectRoot): void
|
||||
{
|
||||
$cachePath = self::cachePath();
|
||||
$affectedPath = self::affectedPath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($affectedPath);
|
||||
$raw = $this->state->read(self::KEY_AFFECTED);
|
||||
|
||||
if ($raw === false) {
|
||||
if ($raw === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -660,7 +738,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$affected = $changed === [] ? [] : $graph->affected($changed);
|
||||
|
||||
$this->changedFileCount = count($changed);
|
||||
$this->affectedTestCount = count($affected);
|
||||
|
||||
$affectedSet = array_fill_keys($affected, true);
|
||||
|
||||
@ -695,32 +772,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function persistAffectedSet(string $projectRoot, array $affected): bool
|
||||
{
|
||||
$path = self::affectedPath();
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return $this->state->write(self::KEY_AFFECTED, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -809,49 +867,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function flushWorkerPartial(string $projectRoot, array $perTest): void
|
||||
{
|
||||
$path = self::workerPath($this->workerToken());
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$json = json_encode($perTest, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
$this->state->write(self::workerEdgesKey($this->workerToken()), $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
* @return list<string> State keys of per-worker edges partials.
|
||||
*/
|
||||
private function collectWorkerPartials(string $projectRoot): array
|
||||
private function collectWorkerEdgesPartials(): array
|
||||
{
|
||||
$pattern = self::workerGlob();
|
||||
$matches = glob($pattern);
|
||||
|
||||
return $matches === false ? [] : $matches;
|
||||
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
|
||||
}
|
||||
|
||||
private function purgeWorkerPartials(string $projectRoot): void
|
||||
{
|
||||
foreach ($this->collectWorkerPartials($projectRoot) as $path) {
|
||||
@unlink($path);
|
||||
foreach ($this->collectWorkerEdgesPartials() as $key) {
|
||||
$this->state->delete($key);
|
||||
}
|
||||
|
||||
foreach ($this->collectWorkerReplayPartials() as $path) {
|
||||
@unlink($path);
|
||||
foreach ($this->collectWorkerReplayPartials() as $key) {
|
||||
$this->state->delete($key);
|
||||
}
|
||||
}
|
||||
|
||||
@ -867,46 +907,29 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
$results = $collector->all();
|
||||
|
||||
if ($results === [] && $this->replayedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $this->workerToken();
|
||||
$path = self::workerResultsPath($token);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
if ($results === [] && $this->replayedCount === 0 && $this->executedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$json = json_encode([
|
||||
'results' => $results,
|
||||
'replayed' => $this->replayedCount,
|
||||
'executed' => $this->executedCount,
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
$this->state->write(self::workerResultsKey($this->workerToken()), $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
* @return list<string> State keys of per-worker replay partials.
|
||||
*/
|
||||
private function collectWorkerReplayPartials(): array
|
||||
{
|
||||
$matches = glob(self::workerResultsGlob());
|
||||
|
||||
return $matches === false ? [] : $matches;
|
||||
return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -919,17 +942,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
/** @var ResultCollector $collector */
|
||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||
|
||||
foreach ($this->collectWorkerReplayPartials() as $path) {
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
@unlink($path);
|
||||
foreach ($this->collectWorkerReplayPartials() as $key) {
|
||||
$raw = $this->state->read($key);
|
||||
$this->state->delete($key);
|
||||
|
||||
if ($raw === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
@unlink($path);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
continue;
|
||||
@ -939,6 +960,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->replayedCount += $decoded['replayed'];
|
||||
}
|
||||
|
||||
if (isset($decoded['executed']) && is_int($decoded['executed'])) {
|
||||
$this->executedCount += $decoded['executed'];
|
||||
}
|
||||
|
||||
if (isset($decoded['results']) && is_array($decoded['results'])) {
|
||||
$normalised = [];
|
||||
|
||||
@ -980,11 +1005,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
/**
|
||||
* @return array<string, array<int, string>>|null
|
||||
*/
|
||||
private function readPartial(string $path): ?array
|
||||
private function readPartial(string $key): ?array
|
||||
{
|
||||
$raw = @file_get_contents($path);
|
||||
$raw = $this->state->read($key);
|
||||
|
||||
if ($raw === false) {
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1039,10 +1064,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function emitReplaySummary(): 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->affectedTestCount,
|
||||
$this->executedCount,
|
||||
$this->replayedCount,
|
||||
));
|
||||
}
|
||||
@ -1050,9 +1079,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private function bumpRecordedSha(): void
|
||||
{
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
@ -1071,7 +1099,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$workingTreeFiles = $changedFiles->since($currentSha) ?? [];
|
||||
$graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
|
||||
|
||||
$graph->save($cachePath);
|
||||
$this->saveGraph($graph);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1090,20 +1118,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($results as $testId => $result) {
|
||||
$graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']);
|
||||
$graph->setResult(
|
||||
$this->branch,
|
||||
$testId,
|
||||
$result['status'],
|
||||
$result['message'],
|
||||
$result['time'],
|
||||
$result['assertions'],
|
||||
);
|
||||
}
|
||||
|
||||
$graph->save($cachePath);
|
||||
$this->saveGraph($graph);
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
|
||||
524
src/Plugins/Tia/BaselineSync.php
Normal file
524
src/Plugins/Tia/BaselineSync.php
Normal file
@ -0,0 +1,524 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Pulls a team-shared TIA baseline on the first `--tia` run so new
|
||||
* contributors and fresh CI workspaces start in replay mode instead of
|
||||
* paying the ~30s record cost.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Fingerprint validation happens back in `Tia::handleParent` after the
|
||||
* blobs are written: a mismatched environment (different PHP version,
|
||||
* composer.lock, etc.) discards the pulled baseline and falls through to
|
||||
* the regular record path.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final 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.
|
||||
*/
|
||||
private const string RELEASE_TAG = 'pest-tia-baseline';
|
||||
|
||||
/**
|
||||
* Asset filenames within the release — mirror the state keys so the
|
||||
* CI publisher and the sync consumer stay in lock-step.
|
||||
*/
|
||||
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
||||
|
||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||
|
||||
public function __construct(
|
||||
private readonly State $state,
|
||||
private readonly OutputInterface $output,
|
||||
private readonly InputInterface $input,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public function fetchIfAvailable(string $projectRoot): bool
|
||||
{
|
||||
$repo = $this->detectGitHubRepo($projectRoot);
|
||||
|
||||
if ($repo === null) {
|
||||
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);
|
||||
|
||||
if ($graphJson === null) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) {
|
||||
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);
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> baseline ready (%s).',
|
||||
$this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')),
|
||||
));
|
||||
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
public function publish(string $projectRoot): int
|
||||
{
|
||||
$graphBytes = $this->state->read(Tia::KEY_GRAPH);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$repo = $this->detectGitHubRepo($projectRoot);
|
||||
|
||||
if ($repo === null) {
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=red>TIA</> cannot infer a GitHub repo from <fg=gray>.git/config</>.',
|
||||
' Publishing is supported only for GitHub-hosted projects.',
|
||||
'',
|
||||
]);
|
||||
|
||||
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</>',
|
||||
'',
|
||||
]);
|
||||
|
||||
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(' <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
|
||||
* creating the release with the assets attached on first run.
|
||||
*
|
||||
* @param array<int, string> $files
|
||||
*/
|
||||
private function ghReleaseUploadOrCreate(string $repo, array $files): int
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
if (! $this->isTerminal()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `.git/config` for the `origin` remote and extracts
|
||||
* `org/repo`. Supports the two URL flavours git emits out of the box.
|
||||
* Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which
|
||||
* silently opts the repo out of auto-sync.
|
||||
*/
|
||||
private function detectGitHubRepo(string $projectRoot): ?string
|
||||
{
|
||||
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||
|
||||
if (! is_file($gitConfig)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($gitConfig);
|
||||
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the `[remote "origin"]` section and the first `url` line
|
||||
// inside it. Tolerates INI whitespace quirks (tabs, CRLF).
|
||||
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $match[1];
|
||||
|
||||
// SSH: git@github.com:org/repo(.git)
|
||||
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
// HTTPS: https://github.com/org/repo(.git) (optional trailing slash)
|
||||
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
{
|
||||
if (! $this->commandExists('gh')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
|
||||
|
||||
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
'gh', 'release', 'download', self::RELEASE_TAG,
|
||||
'-R', $repo,
|
||||
'-p', $asset,
|
||||
'-D', $tmpDir,
|
||||
'--clobber',
|
||||
]);
|
||||
$process->setTimeout(120.0);
|
||||
$process->run();
|
||||
|
||||
$payload = null;
|
||||
|
||||
if ($process->isSuccessful()) {
|
||||
$path = $tmpDir.DIRECTORY_SEPARATOR.$asset;
|
||||
|
||||
if (is_file($path)) {
|
||||
$content = @file_get_contents($path);
|
||||
$payload = $content === false ? null : $content;
|
||||
}
|
||||
}
|
||||
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function downloadViaHttps(string $repo, string $asset): ?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,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = @file_get_contents($url, false, $ctx);
|
||||
|
||||
return $content === false ? null : $content;
|
||||
}
|
||||
|
||||
private function commandExists(string $cmd): bool
|
||||
{
|
||||
$probe = new Process(['command', '-v', $cmd]);
|
||||
$probe->run();
|
||||
|
||||
if ($probe->isSuccessful()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$which = new Process(['which', $cmd]);
|
||||
$which->run();
|
||||
|
||||
return $which->isSuccessful();
|
||||
}
|
||||
|
||||
private function cleanup(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = glob($dir.DIRECTORY_SEPARATOR.'*');
|
||||
|
||||
if ($entries !== false) {
|
||||
foreach ($entries as $entry) {
|
||||
if (is_file($entry)) {
|
||||
@unlink($entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
private function formatSize(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1024 * 1024) {
|
||||
return sprintf('%.1f MB', $bytes / 1024 / 1024);
|
||||
}
|
||||
|
||||
if ($bytes >= 1024) {
|
||||
return sprintf('%.1f KB', $bytes / 1024);
|
||||
}
|
||||
|
||||
return $bytes.' B';
|
||||
}
|
||||
}
|
||||
47
src/Plugins/Tia/Bootstrapper.php
Normal file
47
src/Plugins/Tia/Bootstrapper.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Contracts\Bootstrapper as BootstrapperContract;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\Container;
|
||||
|
||||
/**
|
||||
* Plugin-level container registrations for TIA. Runs as part of Kernel's
|
||||
* bootstrapper chain so Tia's own service graph is set up without Kernel
|
||||
* having to know about any of its internals.
|
||||
*
|
||||
* Most Tia services (`Recorder`, `CoverageCollector`, `WatchPatterns`,
|
||||
* `ResultCollector`, `BaselineSync`) are auto-buildable — Pest's container
|
||||
* resolves them lazily via constructor reflection. The only service that
|
||||
* requires an explicit binding is the `State` contract, because the
|
||||
* filesystem implementation needs a root-directory string that reflection
|
||||
* can't infer.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Bootstrapper implements BootstrapperContract
|
||||
{
|
||||
public function __construct(private Container $container) {}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->container->add(State::class, new FileState($this->tempDir()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.).
|
||||
*/
|
||||
private function tempDir(): string
|
||||
{
|
||||
return __DIR__
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'.temp';
|
||||
}
|
||||
}
|
||||
48
src/Plugins/Tia/Contracts/State.php
Normal file
48
src/Plugins/Tia/Contracts/State.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* Storage contract for TIA's persistent state (graph, baselines, affected
|
||||
* set, worker partials, coverage snapshots). Modelled as a flat key/value
|
||||
* store of raw byte blobs so implementations can sit on top of whatever
|
||||
* backend fits — a directory, a shared cache, a remote object store — and
|
||||
* TIA's logic stays identical.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface State
|
||||
{
|
||||
/**
|
||||
* Returns the stored blob for `$key`, or `null` when the key is unset
|
||||
* or cannot be read.
|
||||
*/
|
||||
public function read(string $key): ?string;
|
||||
|
||||
/**
|
||||
* Atomically stores `$content` under `$key`. Existing value (if any) is
|
||||
* replaced. Implementations SHOULD guarantee that concurrent readers
|
||||
* never observe partial writes.
|
||||
*/
|
||||
public function write(string $key, string $content): bool;
|
||||
|
||||
/**
|
||||
* Removes `$key`. Returns true whether or not the key existed beforehand
|
||||
* — callers should treat a `true` result as "the key is now absent",
|
||||
* not "the key was present and has been removed."
|
||||
*/
|
||||
public function delete(string $key): bool;
|
||||
|
||||
public function exists(string $key): bool;
|
||||
|
||||
/**
|
||||
* Returns every key whose name starts with `$prefix`. Used to collect
|
||||
* paratest worker partials (`tia-worker-<token>.json`, etc.) without
|
||||
* exposing backend-specific glob semantics.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function keysWithPrefix(string $prefix): array;
|
||||
}
|
||||
@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\Container;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use Throwable;
|
||||
|
||||
@ -14,7 +16,7 @@ use Throwable;
|
||||
* executing only the affected tests.
|
||||
*
|
||||
* Invoked from `Pest\Support\Coverage::report()` right before the coverage
|
||||
* file is consumed. A marker file dropped by the `Tia` plugin gates the
|
||||
* file is consumed. A marker dropped by the `Tia` plugin gates the
|
||||
* behaviour — plain `--coverage` runs (no `--tia`) leave the marker absent
|
||||
* and therefore keep their existing semantics.
|
||||
*
|
||||
@ -24,19 +26,17 @@ use Throwable;
|
||||
* Its `ProcessedCodeCoverageData` stores, per source file, per line, the
|
||||
* list of test IDs that covered that line. We:
|
||||
*
|
||||
* 1. Load the cached snapshot (from a previous `--tia --coverage` run).
|
||||
* 1. Load the cached snapshot from `State` (serialised bytes).
|
||||
* 2. Strip every test id that re-ran this time from the cached map —
|
||||
* the tests that ran now are the ones whose attribution is fresh.
|
||||
* 3. Merge the current run into the stripped cached snapshot via
|
||||
* `CodeCoverage::merge()`.
|
||||
* 4. Write the merged result back to the report path (so Pest's report
|
||||
* generator sees the full suite) and to the cache path (for the
|
||||
* generator sees the full suite) and back into `State` (for the
|
||||
* next invocation).
|
||||
* 5. Remove the marker so subsequent plain `--coverage` runs are
|
||||
* untouched.
|
||||
*
|
||||
* If no cache exists yet (first `--tia --coverage` run on this machine)
|
||||
* we simply save the current file as the cache — nothing to merge yet.
|
||||
* we serialise the current object and save it — nothing to merge yet.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@ -44,35 +44,33 @@ final class CoverageMerger
|
||||
{
|
||||
public static function applyIfMarked(string $reportPath): void
|
||||
{
|
||||
$markerPath = Tia::coverageMarkerPath();
|
||||
$state = self::state();
|
||||
|
||||
if (! is_file($markerPath)) {
|
||||
if ($state === null || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@unlink($markerPath);
|
||||
$state->delete(Tia::KEY_COVERAGE_MARKER);
|
||||
|
||||
$cachePath = Tia::coverageCachePath();
|
||||
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
if (! is_file($cachePath)) {
|
||||
// First `--tia --coverage` run: nothing cached yet, the current
|
||||
// report is the full suite itself. Save it verbatim so the next
|
||||
// run has a snapshot to merge against.
|
||||
@copy($reportPath, $cachePath);
|
||||
if ($cachedBytes === null) {
|
||||
// First `--tia --coverage` run: nothing cached yet, so the
|
||||
// current file already represents the full suite. Capture it
|
||||
// verbatim (as serialised bytes) for next time.
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if ($current !== null) {
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var CodeCoverage $cached */
|
||||
$cached = require $cachePath;
|
||||
$cached = self::unserializeCoverage($cachedBytes);
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
/** @var CodeCoverage $current */
|
||||
$current = require $reportPath;
|
||||
} catch (Throwable) {
|
||||
// Corrupt cache or unreadable report — fall back to the plain
|
||||
// PHPUnit behaviour (the existing `require $reportPath` in the
|
||||
// caller still runs against the untouched file).
|
||||
if ($cached === null || $current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -80,15 +78,15 @@ final class CoverageMerger
|
||||
|
||||
$cached->merge($current);
|
||||
|
||||
// Serialise the merged object back using PHPUnit's own "return
|
||||
// expression" PHP format. Using `var_export` on the serialised
|
||||
// payload keeps the file self-contained and independent of
|
||||
// PHPUnit's internal exporter — the reader only needs to
|
||||
// `require` it back.
|
||||
$serialised = "<?php return unserialize(".var_export(serialize($cached), true).");\n";
|
||||
$serialised = serialize($cached);
|
||||
|
||||
@file_put_contents($reportPath, $serialised);
|
||||
@file_put_contents($cachePath, $serialised);
|
||||
// Write back to the PHPUnit-style `.cov` path so the report reader
|
||||
// can `require` it, and to the state cache for the next run.
|
||||
@file_put_contents(
|
||||
$reportPath,
|
||||
"<?php return unserialize(".var_export($serialised, true).");\n",
|
||||
);
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -146,4 +144,43 @@ final class CoverageMerger
|
||||
|
||||
return array_keys($ids);
|
||||
}
|
||||
|
||||
private static function state(): ?State
|
||||
{
|
||||
try {
|
||||
$state = Container::getInstance()->get(State::class);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $state instanceof State ? $state : null;
|
||||
}
|
||||
|
||||
private static function requireCoverage(string $reportPath): ?CodeCoverage
|
||||
{
|
||||
if (! is_file($reportPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var mixed $value */
|
||||
$value = require $reportPath;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value instanceof CodeCoverage ? $value : null;
|
||||
}
|
||||
|
||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
||||
{
|
||||
try {
|
||||
/** @var mixed $value */
|
||||
$value = @unserialize($bytes);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value instanceof CodeCoverage ? $value : null;
|
||||
}
|
||||
}
|
||||
|
||||
150
src/Plugins/Tia/FileState.php
Normal file
150
src/Plugins/Tia/FileState.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
|
||||
/**
|
||||
* Filesystem-backed implementation of the TIA `State` contract. Each key
|
||||
* maps verbatim to a file name under `$rootDir`, so existing `.temp/*.json`
|
||||
* layouts are preserved exactly.
|
||||
*
|
||||
* The root directory is created lazily on first write — callers don't have
|
||||
* to pre-provision it, and reads against a missing directory simply return
|
||||
* `null` rather than throwing.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final 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;
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
{
|
||||
$this->rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
public function read(string $key): ?string
|
||||
{
|
||||
$path = $this->pathFor($key);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytes = @file_get_contents($path);
|
||||
|
||||
return $bytes === false ? null : $bytes;
|
||||
}
|
||||
|
||||
public function write(string $key, string $content): bool
|
||||
{
|
||||
if (! $this->ensureRoot()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = $this->pathFor($key);
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $content) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic rename — on POSIX filesystems this is a single-step
|
||||
// replacement, so concurrent readers never see a half-written file.
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(string $key): bool
|
||||
{
|
||||
$path = $this->pathFor($key);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return @unlink($path);
|
||||
}
|
||||
|
||||
public function exists(string $key): bool
|
||||
{
|
||||
return is_file($this->pathFor($key));
|
||||
}
|
||||
|
||||
public function keysWithPrefix(string $prefix): array
|
||||
{
|
||||
$root = $this->resolvedRoot();
|
||||
|
||||
if ($root === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*';
|
||||
$matches = glob($pattern);
|
||||
|
||||
if ($matches === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
|
||||
foreach ($matches as $path) {
|
||||
$keys[] = basename($path);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path for `$key`. Not part of the interface — used by the
|
||||
* coverage merger and similar callers that need direct filesystem
|
||||
* access (e.g. `require` on a cached PHP file). Consumers that only
|
||||
* deal in bytes should go through `read()` / `write()`.
|
||||
*/
|
||||
public function pathFor(string $key): string
|
||||
{
|
||||
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved root if it exists already, otherwise `null`.
|
||||
* Used by read-side helpers so they don't eagerly create the directory
|
||||
* just to find nothing inside.
|
||||
*/
|
||||
private function resolvedRoot(): ?string
|
||||
{
|
||||
$resolved = @realpath($this->rootDir);
|
||||
|
||||
return $resolved === false ? null : $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the root dir on demand. Returns false only when creation
|
||||
* fails and the directory still isn't there afterwards.
|
||||
*/
|
||||
private function ensureRoot(): bool
|
||||
{
|
||||
if (is_dir($this->rootDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (@mkdir($this->rootDir, 0755, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_dir($this->rootDir);
|
||||
}
|
||||
}
|
||||
@ -60,7 +60,7 @@ final class Graph
|
||||
* @var array<string, array{
|
||||
* sha: ?string,
|
||||
* tree: array<string, string>,
|
||||
* results: array<string, array{status: int, message: string, time: float}>
|
||||
* results: array<string, array{status: int, message: string, time: float, assertions?: int}>
|
||||
* }>
|
||||
*/
|
||||
private array $baselines = [];
|
||||
@ -257,14 +257,36 @@ final class Graph
|
||||
$this->baselines[$branch]['sha'] = $sha;
|
||||
}
|
||||
|
||||
public function setResult(string $branch, string $testId, int $status, string $message, float $time): void
|
||||
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['results'][$testId] = [
|
||||
'status' => $status, 'message' => $message, 'time' => $time,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'time' => $time,
|
||||
'assertions' => $assertions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached assertion count for a test, or `null` if unknown.
|
||||
* Callers use this to feed `addToAssertionCount()` at replay time so
|
||||
* the "Tests: N passed (M assertions)" banner matches the recorded run
|
||||
* instead of defaulting to 1 assertion per test.
|
||||
*/
|
||||
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
if (! isset($baseline['results'][$testId]['assertions'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $baseline['results'][$testId]['assertions'];
|
||||
|
||||
return is_int($value) ? $value : null;
|
||||
}
|
||||
|
||||
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
@ -310,7 +332,7 @@ final class Graph
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float}>}
|
||||
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int}>}
|
||||
*/
|
||||
private function baselineFor(string $branch, string $fallbackBranch): array
|
||||
{
|
||||
@ -374,19 +396,15 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
public static function load(string $projectRoot, string $path): ?self
|
||||
/**
|
||||
* Rebuilds a graph from its JSON representation. Returns `null` when
|
||||
* the payload is missing, unreadable, or schema-incompatible. Separated
|
||||
* from transport (state backend, file, etc.) so tests can feed bytes
|
||||
* directly without touching disk.
|
||||
*/
|
||||
public static function decode(string $json, string $projectRoot): ?self
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (! is_array($data) || ($data['schema'] ?? null) !== 1) {
|
||||
return null;
|
||||
@ -402,14 +420,14 @@ final class Graph
|
||||
return $graph;
|
||||
}
|
||||
|
||||
public function save(string $path): bool
|
||||
/**
|
||||
* Serialises the graph to its JSON on-disk form. Returns `null` if the
|
||||
* payload can't be encoded (extremely rare — pathological UTF-8 only).
|
||||
* Persistence is the caller's responsibility: write the returned bytes
|
||||
* through whatever `State` implementation is in play.
|
||||
*/
|
||||
public function encode(): ?string
|
||||
{
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'schema' => 1,
|
||||
'fingerprint' => $this->fingerprint,
|
||||
@ -418,25 +436,9 @@ final class Graph
|
||||
'baselines' => $this->baselines,
|
||||
];
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return $json === false ? null : $json;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -118,6 +118,17 @@ final class ResultCollector
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Finished subscriber after a test's outcome + assertion
|
||||
* events have all fired. Clears the "currently recording" pointer so
|
||||
* the next test's events don't get mis-attributed.
|
||||
*/
|
||||
public function finishTest(): void
|
||||
{
|
||||
$this->currentTestId = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
private function record(int $status, string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
@ -128,14 +139,17 @@ final class ResultCollector
|
||||
? round(microtime(true) - $this->startTime, 3)
|
||||
: 0.0;
|
||||
|
||||
// PHPUnit can fire more than one outcome event per test — the
|
||||
// canonical case is a risky pass (`Passed` then `ConsideredRisky`).
|
||||
// Last-wins semantics preserve the most specific status; the
|
||||
// existing assertion count (if any) survives the overwrite.
|
||||
$existing = $this->results[$this->currentTestId] ?? null;
|
||||
|
||||
$this->results[$this->currentTestId] = [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'time' => $time,
|
||||
'assertions' => 0,
|
||||
'assertions' => $existing['assertions'] ?? 0,
|
||||
];
|
||||
|
||||
$this->currentTestId = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,5 +30,11 @@ final class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscrib
|
||||
$event->numberOfAssertionsPerformed(),
|
||||
);
|
||||
}
|
||||
|
||||
// Close the "currently recording" window on Finished so the next
|
||||
// test's events don't get mis-attributed. Keeping the pointer open
|
||||
// through the outcome subscribers is what lets a late-firing
|
||||
// `ConsideredRisky` overwrite an earlier `Passed`.
|
||||
$this->collector->finishTest();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user