mirror of
https://github.com/pestphp/pest.git
synced 2026-04-20 22:20:17 +02:00
feat(tia): continues to work on poc
This commit is contained in:
@ -27,6 +27,7 @@ final readonly class BootSubscribers implements Bootstrapper
|
||||
Subscribers\EnsureTeamCityEnabled::class,
|
||||
Subscribers\EnsureTiaCoverageIsRecorded::class,
|
||||
Subscribers\EnsureTiaCoverageIsFlushed::class,
|
||||
Subscribers\EnsureTiaResultsAreCollected::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -5,8 +5,11 @@ declare(strict_types=1);
|
||||
namespace Pest\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Pest\Contracts\Plugins\BeforeEachable;
|
||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugin\Loader;
|
||||
use Pest\Plugins\Tia\CachedTestResult;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\ExceptionTrace;
|
||||
@ -75,6 +78,12 @@ trait Testable
|
||||
*/
|
||||
public bool $__ran = false;
|
||||
|
||||
/**
|
||||
* Set when a `BeforeEachable` plugin returns a cached success result.
|
||||
* Checked in `__runTest` and `tearDown` to skip body + cleanup.
|
||||
*/
|
||||
private bool $__cachedPass = false;
|
||||
|
||||
/**
|
||||
* The test's test closure.
|
||||
*/
|
||||
@ -227,6 +236,31 @@ trait Testable
|
||||
{
|
||||
TestSuite::getInstance()->test = $this;
|
||||
|
||||
$this->__cachedPass = false;
|
||||
|
||||
/** @var BeforeEachable $plugin */
|
||||
foreach (Loader::getPlugins(BeforeEachable::class) as $plugin) {
|
||||
$cached = $plugin->beforeEach(self::$__filename, $this::class.'::'.$this->name());
|
||||
|
||||
if ($cached instanceof CachedTestResult) {
|
||||
if ($cached->isSuccess()) {
|
||||
$this->__cachedPass = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-success: throw appropriate exception. PHPUnit catches
|
||||
// it in runBare() and marks the test with the correct status.
|
||||
// This makes skips, failures, incompletes, todos appear in
|
||||
// output exactly as if the test ran.
|
||||
match ($cached->status) {
|
||||
1 => $this->markTestSkipped($cached->message), // skip / todo
|
||||
2 => $this->markTestIncomplete($cached->message), // incomplete
|
||||
default => throw new \PHPUnit\Framework\AssertionFailedError($cached->message ?: 'Cached failure'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
|
||||
$description = $method->description;
|
||||
@ -302,6 +336,12 @@ trait Testable
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
TestSuite::getInstance()->test = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
||||
|
||||
if ($this->__afterEach instanceof Closure) {
|
||||
@ -327,6 +367,12 @@ trait Testable
|
||||
*/
|
||||
private function __runTest(Closure $closure, ...$args): mixed
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
$this->addToAssertionCount(1);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$arguments = $this->__resolveTestArguments($args);
|
||||
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
||||
|
||||
|
||||
25
src/Contracts/Plugins/BeforeEachable.php
Normal file
25
src/Contracts/Plugins/BeforeEachable.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Contracts\Plugins;
|
||||
|
||||
use Pest\Plugins\Tia\CachedTestResult;
|
||||
|
||||
/**
|
||||
* Plugins implementing this interface are consulted before each test's
|
||||
* `setUp()`. The return value controls what happens:
|
||||
*
|
||||
* - `null` → test proceeds normally.
|
||||
* - `CachedTestResult` → test replays the cached status. For non-success
|
||||
* statuses the appropriate exception is thrown
|
||||
* from `setUp` (PHPUnit handles it natively). For
|
||||
* success, a synthetic assertion is registered and
|
||||
* the body + tearDown are skipped via a flag.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface BeforeEachable
|
||||
{
|
||||
public function beforeEach(string $filename, string $testId): ?CachedTestResult;
|
||||
}
|
||||
@ -67,7 +67,8 @@ final readonly class Kernel
|
||||
->add(OutputInterface::class, $output)
|
||||
->add(Container::class, $container)
|
||||
->add(Tia\Recorder::class, new Tia\Recorder)
|
||||
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns);
|
||||
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
|
||||
->add(Tia\ResultCollector::class, new Tia\ResultCollector);
|
||||
|
||||
$kernel = new self(
|
||||
new Application,
|
||||
|
||||
@ -5,12 +5,15 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins;
|
||||
|
||||
use Pest\Contracts\Plugins\AddsOutput;
|
||||
use Pest\Contracts\Plugins\BeforeEachable;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use Pest\Plugins\Tia\CachedTestResult;
|
||||
use Pest\Plugins\Tia\ChangedFiles;
|
||||
use Pest\Plugins\Tia\Fingerprint;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use Pest\TestCaseFilters\TiaTestCaseFilter;
|
||||
use Pest\Plugins\Tia\WatchPatterns;
|
||||
use Pest\Support\Container;
|
||||
@ -61,7 +64,7 @@ use Throwable;
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Terminable
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
@ -98,6 +101,28 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private bool $graphWritten = false;
|
||||
|
||||
private bool $replayRan = false;
|
||||
|
||||
/**
|
||||
* Holds the graph during replay so `beforeEach` can look up cached
|
||||
* results without re-loading from disk on every test.
|
||||
*/
|
||||
private ?Graph $replayGraph = null;
|
||||
|
||||
/**
|
||||
* Current git branch (or `HEAD` SHA when detached). Resolved once per
|
||||
* run so all graph accesses use the same branch key.
|
||||
*/
|
||||
private string $branch = 'main';
|
||||
|
||||
/**
|
||||
* Test files that are affected (should re-execute). Keyed by
|
||||
* project-relative path. Set during `enterReplayMode`.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private array $affectedFiles = [];
|
||||
|
||||
private static function tempDir(): string
|
||||
{
|
||||
$dir = (string) realpath(self::TEMP_DIR);
|
||||
@ -137,6 +162,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private readonly WatchPatterns $watchPatterns,
|
||||
) {}
|
||||
|
||||
public function beforeEach(string $filename, string $testId): ?CachedTestResult
|
||||
{
|
||||
if ($this->replayGraph === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve file to project-relative path.
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$real = @realpath($filename);
|
||||
$rel = $real !== false
|
||||
? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR)))
|
||||
: null;
|
||||
|
||||
// Affected files must re-execute.
|
||||
if ($rel !== null && isset($this->affectedFiles[$rel])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unknown files (not in graph) must execute — they're new.
|
||||
if ($rel === null || ! $this->replayGraph->knowsTest($rel)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Known + unaffected: return cached result if we have one for this
|
||||
// branch (falls back to main if branch is fresh).
|
||||
return $this->replayGraph->getResult($this->branch, $testId);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@ -211,7 +264,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setRecordedAtSha((new ChangedFiles($projectRoot))->currentSha());
|
||||
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
|
||||
$graph->replaceEdges($perTest);
|
||||
$graph->pruneMissingTests();
|
||||
|
||||
@ -242,6 +295,20 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
// After a successful replay run, advance the recorded SHA to HEAD
|
||||
// so the next run only diffs against what changed since NOW, not
|
||||
// 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) {
|
||||
$this->bumpRecordedSha();
|
||||
}
|
||||
|
||||
// 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') {
|
||||
return $exitCode;
|
||||
}
|
||||
@ -257,7 +324,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setRecordedAtSha((new ChangedFiles($projectRoot))->currentSha());
|
||||
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
|
||||
|
||||
$merged = [];
|
||||
|
||||
@ -317,6 +384,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
// this point).
|
||||
$this->watchPatterns->useDefaults($projectRoot);
|
||||
|
||||
// Resolve current branch once per run so every baseline lookup uses
|
||||
// the same key. Detached HEAD (or no git) falls back to `main` as
|
||||
// the implicit branch identity.
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
$fingerprint = Fingerprint::compute($projectRoot);
|
||||
|
||||
@ -331,10 +403,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
if ($graph instanceof Graph) {
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$branchSha = $graph->recordedAtSha($this->branch);
|
||||
|
||||
if ($changedFiles->gitAvailable()
|
||||
&& $graph->recordedAtSha() !== null
|
||||
&& $changedFiles->since($graph->recordedAtSha()) === null) {
|
||||
&& $branchSha !== null
|
||||
&& $changedFiles->since($branchSha) === null) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> recorded commit is no longer reachable — graph will be rebuilt.',
|
||||
);
|
||||
@ -355,6 +428,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
|
||||
{
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
|
||||
if ($replayingGlobal) {
|
||||
// Replay in a worker: load the graph and the affected set that
|
||||
// the parent persisted, then install the per-file filter so
|
||||
@ -435,7 +510,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$changed = $changedFiles->since($graph->recordedAtSha()) ?? [];
|
||||
$changed = $changedFiles->since($graph->recordedAtSha($this->branch)) ?? [];
|
||||
|
||||
// Drop files whose content hash matches the last-run snapshot. This
|
||||
// is the "dirty but identical" filter: if a file is uncommitted but
|
||||
// its content hasn't moved since the last `--tia` invocation, its
|
||||
// dependents already re-ran last time and don't need re-running
|
||||
// again.
|
||||
$changed = $changedFiles->filterUnchangedSinceLastRun($changed, $graph->lastRunTree($this->branch));
|
||||
|
||||
$affected = $changed === [] ? [] : $graph->affected($changed);
|
||||
|
||||
$totalKnown = count($graph->allTestFiles());
|
||||
@ -445,13 +528,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$testSuite = TestSuite::getInstance();
|
||||
$affectedSet = array_fill_keys($affected, true);
|
||||
|
||||
if (! Parallel::isEnabled()) {
|
||||
$testSuite->tests->addTestCaseFilter(
|
||||
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
|
||||
);
|
||||
$this->replayRan = true;
|
||||
$this->replayGraph = $graph;
|
||||
$this->affectedFiles = $affectedSet;
|
||||
|
||||
if (! Parallel::isEnabled()) {
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> %d changed file(s) → %d affected, %d cached.',
|
||||
' <fg=green>TIA</> %d changed file(s) → %d affected, %d replayed.',
|
||||
count($changed),
|
||||
$affectedCount,
|
||||
$cachedCount,
|
||||
@ -659,6 +742,80 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful replay, bump the graph's `recorded_at_sha` to the
|
||||
* current HEAD. This way the next `--tia` run diffs only against what
|
||||
* changed since THIS run, not since the original recording.
|
||||
*
|
||||
* The graph edges themselves are untouched — only the SHA marker moves.
|
||||
*/
|
||||
/**
|
||||
* After a successful replay, advance the baseline: bump `recorded_at_sha`
|
||||
* to the current HEAD (handles committed changes) and snapshot the
|
||||
* working tree's content hashes (handles uncommitted changes). Next run
|
||||
* compares against this baseline so identical files are skipped even if
|
||||
* git still reports them as modified.
|
||||
*/
|
||||
private function bumpRecordedSha(): void
|
||||
{
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$currentSha = $changedFiles->currentSha();
|
||||
|
||||
if ($currentSha !== null) {
|
||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||
}
|
||||
|
||||
// Snapshot the working tree: hash every currently-modified file.
|
||||
// On next run, files still appearing as modified but whose hash
|
||||
// matches this snapshot are treated as unchanged.
|
||||
$workingTreeFiles = $changedFiles->since($currentSha) ?? [];
|
||||
$graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
|
||||
|
||||
$graph->save($cachePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges per-test status + message from the `ResultCollector` into the
|
||||
* TIA graph. Runs after every `--tia` invocation so the graph always has
|
||||
* fresh results for faithful replay (pass, fail, skip, todo, etc.).
|
||||
*/
|
||||
private function snapshotTestResults(): void
|
||||
{
|
||||
/** @var ResultCollector $collector */
|
||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||
|
||||
$results = $collector->all();
|
||||
|
||||
if ($results === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($results as $testId => $result) {
|
||||
$graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']);
|
||||
}
|
||||
|
||||
$graph->save($cachePath);
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
private function coverageReportActive(): bool
|
||||
{
|
||||
try {
|
||||
|
||||
33
src/Plugins/Tia/CachedTestResult.php
Normal file
33
src/Plugins/Tia/CachedTestResult.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Immutable snapshot of a previous test run's outcome. Stored in the TIA
|
||||
* graph and returned by `BeforeEachable::beforeEach` so `Testable` can
|
||||
* faithfully replay the exact status — pass, fail, skip, todo, incomplete,
|
||||
* risky, etc. — without executing the test body.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class CachedTestResult
|
||||
{
|
||||
/**
|
||||
* PHPUnit TestStatus int constants:
|
||||
* 0 = success, 1 = skipped, 2 = incomplete,
|
||||
* 3 = notice, 4 = deprecation, 5 = risky,
|
||||
* 6 = warning, 7 = failure, 8 = error.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $status,
|
||||
public string $message = '',
|
||||
public float $time = 0.0,
|
||||
) {}
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->status === 0;
|
||||
}
|
||||
}
|
||||
@ -32,6 +32,84 @@ final readonly class ChangedFiles
|
||||
* from HEAD (rebase / force-push) — in
|
||||
* that case the graph should be rebuilt.
|
||||
*/
|
||||
/**
|
||||
* Removes files whose current content hash matches the snapshot from the
|
||||
* last `--tia` run. Used to ignore "dirty but unchanged" files — a file
|
||||
* that git still reports as modified but whose content is bit-identical
|
||||
* to the previous TIA invocation.
|
||||
*
|
||||
* @param array<int, string> $files project-relative paths.
|
||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
|
||||
{
|
||||
if ($lastRunTree === []) {
|
||||
return $files;
|
||||
}
|
||||
|
||||
$remaining = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (! isset($lastRunTree[$file])) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// File deleted since last run — definitely changed.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = @hash_file('xxh128', $absolute);
|
||||
|
||||
if ($hash === false || $hash !== $lastRunTree[$file]) {
|
||||
$remaining[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes content hashes for the given project-relative files. Used to
|
||||
* snapshot the working tree after a successful run so the next run can
|
||||
* detect which files are actually different.
|
||||
*
|
||||
* @param array<int, string> $files
|
||||
* @return array<string, string> path → xxh128 content hash
|
||||
*/
|
||||
public function snapshotTree(array $files): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = @hash_file('xxh128', $absolute);
|
||||
|
||||
if ($hash !== false) {
|
||||
$out[$file] = $hash;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||
* the recorded SHA is no longer reachable
|
||||
* from HEAD (rebase / force-push).
|
||||
*/
|
||||
public function since(?string $sha): ?array
|
||||
{
|
||||
if (! $this->gitAvailable()) {
|
||||
@ -87,6 +165,24 @@ final readonly class ChangedFiles
|
||||
return false;
|
||||
}
|
||||
|
||||
public function currentBranch(): ?string
|
||||
{
|
||||
if (! $this->gitAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$branch = trim($process->getOutput());
|
||||
|
||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
||||
}
|
||||
|
||||
public function gitAvailable(): bool
|
||||
{
|
||||
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
|
||||
|
||||
@ -47,9 +47,22 @@ final class Graph
|
||||
private array $fingerprint = [];
|
||||
|
||||
/**
|
||||
* Commit SHA the graph was recorded against (if in a git repo).
|
||||
* Per-branch baselines. Each branch independently tracks:
|
||||
* - `sha` — last HEAD at which `--tia` ran on this branch
|
||||
* - `tree` — content hashes of modified files at that point
|
||||
* - `results` — per-test status + message + time
|
||||
*
|
||||
* Graph edges (test → source) stay shared across branches because
|
||||
* structure doesn't change per branch. Only run-state is per-branch so
|
||||
* a failing test on one branch doesn't poison another branch's replay.
|
||||
*
|
||||
* @var array<string, array{
|
||||
* sha: ?string,
|
||||
* tree: array<string, string>,
|
||||
* results: array<string, array{status: int, message: string, time: float}>
|
||||
* }>
|
||||
*/
|
||||
private ?string $recordedAtSha = null;
|
||||
private array $baselines = [];
|
||||
|
||||
/**
|
||||
* Canonicalised project root. Resolved through `realpath()` so paths
|
||||
@ -224,14 +237,84 @@ final class Graph
|
||||
return $this->fingerprint;
|
||||
}
|
||||
|
||||
public function setRecordedAtSha(?string $sha): void
|
||||
/**
|
||||
* Returns the SHA the given branch last ran against, or falls back to
|
||||
* `$fallbackBranch` (typically `main`) when this branch has no baseline
|
||||
* yet. That way a freshly-created feature branch inherits main's
|
||||
* baseline on its first run.
|
||||
*/
|
||||
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
|
||||
{
|
||||
$this->recordedAtSha = $sha;
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
return $baseline['sha'];
|
||||
}
|
||||
|
||||
public function recordedAtSha(): ?string
|
||||
public function setRecordedAtSha(string $branch, ?string $sha): void
|
||||
{
|
||||
return $this->recordedAtSha;
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['sha'] = $sha;
|
||||
}
|
||||
|
||||
public function setResult(string $branch, string $testId, int $status, string $message, float $time): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['results'][$testId] = [
|
||||
'status' => $status, 'message' => $message, 'time' => $time,
|
||||
];
|
||||
}
|
||||
|
||||
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?CachedTestResult
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
if (! isset($baseline['results'][$testId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$r = $baseline['results'][$testId];
|
||||
|
||||
return new CachedTestResult($r['status'], $r['message'], $r['time']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $tree project-relative path → content hash
|
||||
*/
|
||||
public function setLastRunTree(string $branch, array $tree): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['tree'] = $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array
|
||||
{
|
||||
return $this->baselineFor($branch, $fallbackBranch)['tree'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float}>}
|
||||
*/
|
||||
private function baselineFor(string $branch, string $fallbackBranch): array
|
||||
{
|
||||
if (isset($this->baselines[$branch])) {
|
||||
return $this->baselines[$branch];
|
||||
}
|
||||
|
||||
if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) {
|
||||
return $this->baselines[$fallbackBranch];
|
||||
}
|
||||
|
||||
return ['sha' => null, 'tree' => [], 'results' => []];
|
||||
}
|
||||
|
||||
private function ensureBaseline(string $branch): void
|
||||
{
|
||||
if (! isset($this->baselines[$branch])) {
|
||||
$this->baselines[$branch] = ['sha' => null, 'tree' => [], 'results' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -296,10 +379,10 @@ final class Graph
|
||||
|
||||
$graph = new self($projectRoot);
|
||||
$graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
|
||||
$graph->recordedAtSha = is_string($data['recorded_at_sha'] ?? null) ? $data['recorded_at_sha'] : null;
|
||||
$graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : [];
|
||||
$graph->fileIds = array_flip($graph->files);
|
||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
||||
|
||||
return $graph;
|
||||
}
|
||||
@ -315,9 +398,9 @@ final class Graph
|
||||
$payload = [
|
||||
'schema' => 1,
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'recorded_at_sha' => $this->recordedAtSha,
|
||||
'files' => $this->files,
|
||||
'edges' => $this->edges,
|
||||
'baselines' => $this->baselines,
|
||||
];
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
119
src/Plugins/Tia/ResultCollector.php
Normal file
119
src/Plugins/Tia/ResultCollector.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Collects per-test status + message during the run so the graph can persist
|
||||
* them for faithful replay. PHPUnit's own result cache discards messages
|
||||
* during serialisation — this collector retains them.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ResultCollector
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{status: int, message: string, time: float}>
|
||||
*/
|
||||
private array $results = [];
|
||||
|
||||
private ?string $currentTestId = null;
|
||||
|
||||
private ?float $startTime = null;
|
||||
|
||||
public function testPrepared(string $testId): void
|
||||
{
|
||||
$this->currentTestId = $testId;
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
|
||||
public function testPassed(): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(0, '');
|
||||
}
|
||||
|
||||
public function testFailed(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(7, $message);
|
||||
}
|
||||
|
||||
public function testErrored(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(8, $message);
|
||||
}
|
||||
|
||||
public function testSkipped(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(1, $message);
|
||||
}
|
||||
|
||||
public function testIncomplete(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(2, $message);
|
||||
}
|
||||
|
||||
public function testRisky(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(5, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{status: int, message: string, time: float}>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->results = [];
|
||||
$this->currentTestId = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
private function record(int $status, string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$time = $this->startTime !== null
|
||||
? round(microtime(true) - $this->startTime, 3)
|
||||
: 0.0;
|
||||
|
||||
$this->results[$this->currentTestId] = [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'time' => $time,
|
||||
];
|
||||
|
||||
$this->currentTestId = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
}
|
||||
86
src/Subscribers/EnsureTiaResultsAreCollected.php
Normal file
86
src/Subscribers/EnsureTiaResultsAreCollected.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Test\ConsideredRisky;
|
||||
use PHPUnit\Event\Test\ConsideredRiskySubscriber;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\ErroredSubscriber;
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
use PHPUnit\Event\Test\FailedSubscriber;
|
||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
||||
use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
|
||||
use PHPUnit\Event\Test\Passed;
|
||||
use PHPUnit\Event\Test\PassedSubscriber;
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||
|
||||
/**
|
||||
* Feeds per-test outcomes (status + message + time) into the TIA
|
||||
* `ResultCollector` so the graph can persist them for faithful replay.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaResultsAreCollected implements
|
||||
ConsideredRiskySubscriber,
|
||||
ErroredSubscriber,
|
||||
FailedSubscriber,
|
||||
MarkedIncompleteSubscriber,
|
||||
PassedSubscriber,
|
||||
PreparedSubscriber,
|
||||
SkippedSubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
|
||||
public function notify(Prepared|Passed|Failed|Errored|Skipped|MarkedIncomplete|ConsideredRisky $event): void
|
||||
{
|
||||
if ($event instanceof Prepared) {
|
||||
$test = $event->test();
|
||||
|
||||
if ($test instanceof TestMethod) {
|
||||
$this->collector->testPrepared($test->className().'::'.$test->methodName());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event instanceof Passed) {
|
||||
$this->collector->testPassed();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event instanceof Failed) {
|
||||
$this->collector->testFailed($event->throwable()->message());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event instanceof Errored) {
|
||||
$this->collector->testErrored($event->throwable()->message());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event instanceof Skipped) {
|
||||
$this->collector->testSkipped($event->message());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event instanceof MarkedIncomplete) {
|
||||
$this->collector->testIncomplete($event->throwable()->message());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Last possible type: ConsideredRisky (all others returned above).
|
||||
$this->collector->testRisky($event->message()); // @phpstan-ignore method.notFound
|
||||
}
|
||||
}
|
||||
@ -37,6 +37,7 @@ arch('contracts')
|
||||
->toOnlyUse([
|
||||
'NunoMaduro\Collision\Contracts',
|
||||
'Pest\Factories\TestCaseMethodFactory',
|
||||
'Pest\Plugins\Tia\CachedTestResult',
|
||||
'Symfony\Component\Console',
|
||||
'Pest\Arch\Contracts',
|
||||
'Pest\PendingCalls',
|
||||
|
||||
Reference in New Issue
Block a user