mirror of
https://github.com/pestphp/pest.git
synced 2026-09-07 07:13:35 +02:00
wip
This commit is contained in:
@@ -1568,6 +1568,34 @@
|
||||
PASS Tests\Features\Tia
|
||||
✓ it does not run user hooks when replaying cached skipped and incomplete results
|
||||
|
||||
PASS Tests\Features\Tia\DefaultBranchReplay
|
||||
✓ replays the default branch baseline on a new branch
|
||||
✓ replays whatever the default branch is called with ('main')
|
||||
✓ replays whatever the default branch is called with ('master')
|
||||
✓ replays whatever the default branch is called with ('trunk')
|
||||
✓ replays whatever the default branch is called with ('develop')
|
||||
✓ replays on a second new branch too
|
||||
✓ writes nothing on a second run on the same branch
|
||||
✓ replays on a branch whose name contains slashes
|
||||
✓ replays inside a worktree on a new branch
|
||||
|
||||
PASS Tests\Features\Tia\DefaultBranchResolution
|
||||
✓ a declared default branch beats autodetection
|
||||
✓ a declared default branch that does not exist degrades to a full run
|
||||
✓ a repository with no remote is refused rather than silently re-run
|
||||
✓ a declared default branch stands in for a missing remote
|
||||
✓ tia still requires git
|
||||
✓ a plain run outside a repository creates no baseline
|
||||
✓ the default branch is resolved once per run, not once per test
|
||||
|
||||
PASS Tests\Features\Tia\DefaultBranchWriteTier
|
||||
✓ narrows to the affected tests on a new branch
|
||||
✓ filtered mode reads the fallback too
|
||||
✓ filtered mode finds nothing to do on a clean green feature branch
|
||||
✓ a detached HEAD replays without minting a branch key
|
||||
✓ the branch that ran gets its own key and the default branch keeps its baseline
|
||||
✓ the fallback reaches parallel workers
|
||||
|
||||
PASS Tests\Features\Ticket
|
||||
✓ it may be associated with an ticket #1, #2
|
||||
✓ nested → it may be associated with an ticket #1, #4, #5, #6, #3
|
||||
@@ -2197,4 +2225,4 @@
|
||||
✓ pass with dataset with ('my-datas-set-value')
|
||||
✓ within describe → pass with dataset with ('my-datas-set-value')
|
||||
|
||||
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1549 passed (3389 assertions)
|
||||
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1571 passed (3446 assertions)
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Fixtures\Tia\Project;
|
||||
|
||||
/**
|
||||
* Reading a baseline recorded on another branch.
|
||||
*
|
||||
* Without the default-branch fallback, the first `--tia` run on every new branch
|
||||
* re-runs a whole suite whose results the default branch already holds — once
|
||||
* per branch, forever, on any repository not named `main`.
|
||||
*
|
||||
* @see https://github.com/pestphp/pest/issues/1823
|
||||
*/
|
||||
afterEach(function (): void {
|
||||
Project::destroyAll();
|
||||
});
|
||||
|
||||
test('replays the default branch baseline on a new branch', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->uncached())->toBe(0, $result->describe())
|
||||
->and($result->exitCode)->toBe(0);
|
||||
})->skipOnWindows();
|
||||
|
||||
test('replays whatever the default branch is called', function (string $defaultBranch): void {
|
||||
$project = Project::make($defaultBranch);
|
||||
$project->seed($defaultBranch);
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->uncached())->toBe(0, $result->describe());
|
||||
})->with(['main', 'master', 'trunk', 'develop'])->skipOnWindows();
|
||||
|
||||
test('replays on a second new branch too', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
$project->pest('--tia');
|
||||
|
||||
$project->git()->switchTo('master');
|
||||
$project->git()->switchTo('feature-y', new: true);
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
// The toll is one full run per new branch. It must not come back for the
|
||||
// second branch either.
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->uncached())->toBe(0, $result->describe());
|
||||
})->skipOnWindows();
|
||||
|
||||
test('writes nothing on a second run on the same branch', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
$project->pest('--tia');
|
||||
|
||||
$project->snapshot();
|
||||
$result = $project->pest('--tia');
|
||||
$delta = $project->delta();
|
||||
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($delta->writtenCount())->toBe(0, $delta->summary())
|
||||
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
|
||||
})->skipOnWindows();
|
||||
|
||||
test('replays on a branch whose name contains slashes', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature/x/y', new: true);
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->uncached())->toBe(0, $result->describe())
|
||||
->and($project->branchKeys())->toContain('feature/x/y');
|
||||
})->skipOnWindows();
|
||||
|
||||
test('replays inside a worktree on a new branch', function (): void {
|
||||
$project = Project::make('master');
|
||||
$worktree = $project->worktree('feature-worktree');
|
||||
|
||||
// Seeded against the worktree rather than the main checkout: a worktree's
|
||||
// `.git` is a file, so `Storage::originIdentity()` cannot read the remote
|
||||
// from it and the worktree resolves a storage key of its own. That gap is
|
||||
// separate from the branch fallback, and it is the fallback this row is
|
||||
// about — the worktree is checked out on a branch the baseline does not
|
||||
// name, which is the scenario from the issue.
|
||||
$project->seedFor($worktree, 'master');
|
||||
|
||||
$result = $project->pestIn($worktree, '--tia');
|
||||
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->uncached())->toBe(0, $result->describe());
|
||||
})->skipOnWindows();
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\Process\ExecutableFinder;
|
||||
use Tests\Fixtures\Tia\Project;
|
||||
|
||||
/**
|
||||
* How the default branch gets named: declared in `tests/Pest.php`, autodetected
|
||||
* from the repository, or not answerable at all.
|
||||
*/
|
||||
afterEach(function (): void {
|
||||
Project::destroyAll();
|
||||
});
|
||||
|
||||
test('a declared default branch beats autodetection', function (): void {
|
||||
// The repository autodetects `develop`, which holds no baseline. Only the
|
||||
// declaration in `tests/Pest.php` can reach the `master` one.
|
||||
$project = Project::make('develop', overlay: 'configured-default-branch');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->uncached())->toBe(0, $result->describe());
|
||||
})->skipOnWindows();
|
||||
|
||||
test('a declared default branch that does not exist degrades to a full run', function (): void {
|
||||
$project = Project::make('master', overlay: 'unknown-default-branch');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
// Nothing to fall back to, so everything runs — and no baseline is minted
|
||||
// under the name that resolved to nothing.
|
||||
expect($result->uncached())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->exitCode)->toBe(0, $result->describe())
|
||||
->and($project->branchKeys())->toBe(['master', 'feature-x']);
|
||||
})->skipOnWindows();
|
||||
|
||||
test('a repository with no remote is refused rather than silently re-run', function (): void {
|
||||
$project = Project::make('master');
|
||||
|
||||
$project->git()->removeOrigin();
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
// Nothing can name the default branch, so every new branch would re-run the
|
||||
// whole suite with no explanation. Saying so beats doing that quietly.
|
||||
expect($result->output)->toContain('Tia mode requires a repository with a remote.')
|
||||
->and($result->exitCode)->toBe(1, $result->describe());
|
||||
})->skipOnWindows();
|
||||
|
||||
test('a declared default branch stands in for a missing remote', function (): void {
|
||||
// The escape hatch the refusal above points at: with the branch named by
|
||||
// hand there is nothing left for a remote to answer.
|
||||
$project = Project::make('master', overlay: 'configured-default-branch');
|
||||
|
||||
$project->git()->removeOrigin();
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->uncached())->toBe(0, $result->describe());
|
||||
})->skipOnWindows();
|
||||
|
||||
test('tia still requires git', function (): void {
|
||||
$project = Project::withoutGit();
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
// The soft default-branch resolver runs before the branch is named, and it
|
||||
// must not swallow this.
|
||||
expect($result->output)->toContain('The feature "Tia mode" requires "git".')
|
||||
->and($result->exitCode)->not->toBe(0);
|
||||
})->skipOnWindows();
|
||||
|
||||
test('a plain run outside a repository creates no baseline', function (): void {
|
||||
$project = Project::withoutGit();
|
||||
|
||||
$result = $project->pest();
|
||||
|
||||
expect($result->exitCode)->toBe(0, $result->describe())
|
||||
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
|
||||
->and($project->graphExists())->toBeFalse()
|
||||
->and($project->graphDir())->not->toBeDirectory();
|
||||
})->skipOnWindows();
|
||||
|
||||
test('the default branch is resolved once per run, not once per test', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$git = (new ExecutableFinder)->find('git');
|
||||
|
||||
expect($git)->not->toBeNull();
|
||||
|
||||
// A `git` first on `PATH` that records what it was asked before handing over
|
||||
// to the real one.
|
||||
$log = $project->path('git-calls.log');
|
||||
$project->write('shim/git', implode("\n", [
|
||||
'#!/bin/sh',
|
||||
'echo "$@" >> '.escapeshellarg($log),
|
||||
'exec '.escapeshellarg((string) $git).' "$@"',
|
||||
'',
|
||||
]));
|
||||
chmod($project->path('shim/git'), 0755);
|
||||
|
||||
$result = $project->pestWithEnvironment($project->path(), [
|
||||
'PATH' => $project->path('shim').':'.getenv('PATH'),
|
||||
], '--tia');
|
||||
|
||||
$calls = file_exists($log) ? explode("\n", trim((string) file_get_contents($log))) : [];
|
||||
$resolutions = array_filter($calls, fn (string $call): bool => str_contains($call, 'symbolic-ref')
|
||||
|| str_contains($call, 'init.defaultBranch'));
|
||||
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($calls)->not->toBeEmpty('the git shim was never reached')
|
||||
->and($resolutions)->toHaveCount(1, implode("\n", $calls));
|
||||
})->skipOnWindows();
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Fixtures\Tia\Project;
|
||||
|
||||
/**
|
||||
* What the fallback is allowed to touch.
|
||||
*
|
||||
* Reading another branch's baseline must stay a read: writes belong to the
|
||||
* branch that ran, and a branch that has no name of its own must not mint one.
|
||||
*/
|
||||
afterEach(function (): void {
|
||||
Project::destroyAll();
|
||||
});
|
||||
|
||||
test('narrows to the affected tests on a new branch', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
// Semantic, not cosmetic: PHP is hashed at the AST level, so a comment
|
||||
// would not register as a change at all.
|
||||
$project->write('app/Calculator.php', <<<'PHP'
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fixture\App;
|
||||
|
||||
final class Calculator
|
||||
{
|
||||
public function add(int $a, int $b): int
|
||||
{
|
||||
return $a + $b;
|
||||
}
|
||||
|
||||
public function subtract(int $a, int $b): int
|
||||
{
|
||||
return $a - $b;
|
||||
}
|
||||
|
||||
public function multiply(int $a, int $b): int
|
||||
{
|
||||
return $a * $b;
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
// Two of the three test files cover `Calculator`; the third replays from the
|
||||
// default branch's baseline.
|
||||
expect($result->affected())->toBe(4, $result->describe())
|
||||
->and($result->replayed())->toBe(2, $result->describe())
|
||||
->and($result->exitCode)->toBe(0, $result->describe());
|
||||
})->skipOnWindows();
|
||||
|
||||
test('filtered mode reads the fallback too', function (): void {
|
||||
$project = Project::make('master');
|
||||
|
||||
// A failure cached on the default branch. Filtered mode asks the graph which
|
||||
// test files are due a re-run, and that read has to reach the fallback as
|
||||
// well: without it a new branch sees nothing to do, reports green, and never
|
||||
// re-runs the failure — on every subsequent invocation.
|
||||
$project->seed('master', failing: ['adds two numbers']);
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$result = $project->pest('--tia', '--filtered');
|
||||
|
||||
// Selection is by file, so the failure's two siblings in `CalculatorTest`
|
||||
// come along; the other two test files stay out of the run entirely.
|
||||
expect($result->output)->toContain('from 1 previously unsuccessful test')
|
||||
->and($result->output)->not->toContain('No affected tests found')
|
||||
->and($result->tally())->toContain('2 passed');
|
||||
})->skipOnWindows();
|
||||
|
||||
test('filtered mode finds nothing to do on a clean green feature branch', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$result = $project->pest('--tia', '--filtered');
|
||||
$delta = $project->delta();
|
||||
|
||||
expect($result->output)->toContain('No affected tests found')
|
||||
->and($result->exitCode)->toBe(0, $result->describe())
|
||||
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
|
||||
})->skipOnWindows();
|
||||
|
||||
test('a detached HEAD replays without minting a branch key', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->detach();
|
||||
|
||||
$result = $project->pest('--tia');
|
||||
|
||||
// A detached HEAD has no branch of its own. The default branch is the
|
||||
// honest key, and no phantom one appears beside it.
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->uncached())->toBe(0, $result->describe())
|
||||
->and($project->branchKeys())->toBe(['master']);
|
||||
})->skipOnWindows();
|
||||
|
||||
test('the branch that ran gets its own key and the default branch keeps its baseline', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
$project->pest('--tia');
|
||||
|
||||
$delta = $project->delta();
|
||||
|
||||
expect($project->branchKeys())->toBe(['master', 'feature-x'])
|
||||
->and($delta->baselineUntouched('master'))->toBeTrue($delta->summary())
|
||||
->and($delta->writtenCount())->toBe(0, $delta->summary());
|
||||
})->skipOnWindows();
|
||||
|
||||
test('the fallback reaches parallel workers', function (): void {
|
||||
$project = Project::make('master');
|
||||
$project->seed('master');
|
||||
|
||||
$project->git()->switchTo('feature-x', new: true);
|
||||
|
||||
$result = $project->pest('--tia', '--parallel', '--processes=2');
|
||||
|
||||
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
|
||||
->and($result->uncached())->toBe(0, $result->describe());
|
||||
})->skipOnWindows();
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Fixtures\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Storage;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* A git repository the scenario tests drive.
|
||||
*
|
||||
* Hermetic by construction: every invocation neutralises the machine's global
|
||||
* and system config and carries its own committer identity. Without that, an
|
||||
* ambient `init.defaultBranch = main` would answer questions the scenario means
|
||||
* to leave unanswered, and rows like "resolves to nothing, degrades safely"
|
||||
* would pass by accident.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class GitRepo
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const array ENV = [
|
||||
'GIT_CONFIG_GLOBAL' => '/dev/null',
|
||||
'GIT_CONFIG_SYSTEM' => '/dev/null',
|
||||
// `GIT_CONFIG_SYSTEM` does not cover every system-level file git reads:
|
||||
// Apple's git also loads one from inside Xcode, and it sets
|
||||
// `init.defaultBranch`. Only this suppresses all of them.
|
||||
'GIT_CONFIG_NOSYSTEM' => '1',
|
||||
'GIT_AUTHOR_NAME' => 'Pest Fixture',
|
||||
'GIT_AUTHOR_EMAIL' => 'fixture@pestphp.io',
|
||||
'GIT_COMMITTER_NAME' => 'Pest Fixture',
|
||||
'GIT_COMMITTER_EMAIL' => 'fixture@pestphp.io',
|
||||
];
|
||||
|
||||
public function __construct(public string $path) {}
|
||||
|
||||
/**
|
||||
* Initialises the repository on `$branch` and commits everything in it.
|
||||
*/
|
||||
public function init(string $branch = 'master'): void
|
||||
{
|
||||
$this->run(['init', '--quiet']);
|
||||
$this->run(['checkout', '--quiet', '-b', $branch]);
|
||||
$this->commit('Initial commit');
|
||||
}
|
||||
|
||||
public function commit(string $message): void
|
||||
{
|
||||
$this->run(['add', '-A']);
|
||||
$this->run(['commit', '--quiet', '--allow-empty', '-m', $message]);
|
||||
}
|
||||
|
||||
public function switchTo(string $branch, bool $new = false): void
|
||||
{
|
||||
$this->run($new ? ['checkout', '--quiet', '-b', $branch] : ['checkout', '--quiet', $branch]);
|
||||
}
|
||||
|
||||
public function rename(string $from, string $to): void
|
||||
{
|
||||
$this->run(['branch', '-m', $from, $to]);
|
||||
}
|
||||
|
||||
public function detach(): void
|
||||
{
|
||||
$this->run(['checkout', '--quiet', '--detach']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an `origin`, which also decides the graph's storage key:
|
||||
* {@see Storage::projectKey()} prefers the remote's
|
||||
* identity over the path, so two checkouts of one repository — a worktree,
|
||||
* say — share a single graph.
|
||||
*/
|
||||
public function addOrigin(string $url = 'git@github.com:pestphp/tia-fixture.git'): void
|
||||
{
|
||||
$this->run(['remote', 'add', 'origin', $url]);
|
||||
}
|
||||
|
||||
public function removeOrigin(): void
|
||||
{
|
||||
$this->run(['remote', 'remove', 'origin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Points `refs/remotes/origin/HEAD` at a local branch — what
|
||||
* `git remote set-head` would write, without a remote to talk to.
|
||||
*/
|
||||
public function setOriginHead(string $branch): void
|
||||
{
|
||||
$this->run(['update-ref', 'refs/remotes/origin/'.$branch, 'HEAD']);
|
||||
$this->run(['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/'.$branch]);
|
||||
}
|
||||
|
||||
public function config(string $key, string $value): void
|
||||
{
|
||||
$this->run(['config', '--local', $key, $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a worktree for a new branch and returns its path.
|
||||
*/
|
||||
public function worktree(string $path, string $branch): string
|
||||
{
|
||||
$this->run(['worktree', 'add', '--quiet', '-b', $branch, $path]);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function sha(): string
|
||||
{
|
||||
return $this->output(['rev-parse', 'HEAD']);
|
||||
}
|
||||
|
||||
public function currentBranch(): string
|
||||
{
|
||||
return $this->output(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function branchNames(): array
|
||||
{
|
||||
$names = explode("\n", $this->output(['for-each-ref', '--format=%(refname:short)', 'refs/heads']));
|
||||
|
||||
return array_values(array_filter($names, fn (string $name): bool => $name !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function output(array $arguments): string
|
||||
{
|
||||
return trim($this->process($arguments, mustSucceed: true)->getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function run(array $arguments): void
|
||||
{
|
||||
$this->process($arguments, mustSucceed: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
private function process(array $arguments, bool $mustSucceed): Process
|
||||
{
|
||||
$process = new Process(['git', ...$arguments], $this->path, self::ENV);
|
||||
$process->setTimeout(30.0);
|
||||
$process->run();
|
||||
|
||||
if ($mustSucceed && ! $process->isSuccessful()) {
|
||||
throw new RuntimeException(sprintf(
|
||||
"git %s failed in [%s]:\n%s",
|
||||
implode(' ', $arguments),
|
||||
$this->path,
|
||||
$process->getErrorOutput().$process->getOutput(),
|
||||
));
|
||||
}
|
||||
|
||||
return $process;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Fixtures\Tia;
|
||||
|
||||
/**
|
||||
* What one run did to the graph.
|
||||
*
|
||||
* The three tiers a run may respect, from the conformance matrix:
|
||||
*
|
||||
* - COMPLETE — may change everything.
|
||||
* - RESULTS-ONLY — may change only `baselines[<branch>].results` for tests that
|
||||
* actually ran. It may never remove an entry, nor move `sha`, `tree`,
|
||||
* `edges`, `files` or `fingerprint`.
|
||||
* - HARD-SUPPRESSED — may change nothing at all.
|
||||
*
|
||||
* {@see self::writtenCount()} is the load-bearing measurement, and the reason
|
||||
* {@see Project::sentinel()} exists: without falsified cached values there is no
|
||||
* way to tell "wrote the same values back" from "wrote nothing".
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class GraphDelta
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $before
|
||||
* @param array<string, mixed>|null $after
|
||||
*/
|
||||
public function __construct(
|
||||
private ?array $before,
|
||||
private ?array $after,
|
||||
) {}
|
||||
|
||||
public function graphWasCreated(): bool
|
||||
{
|
||||
return $this->before === null && $this->after !== null;
|
||||
}
|
||||
|
||||
public function graphWasDeleted(): bool
|
||||
{
|
||||
return $this->before !== null && $this->after === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result entries whose stored values actually moved.
|
||||
*/
|
||||
public function writtenCount(): int
|
||||
{
|
||||
$written = 0;
|
||||
|
||||
foreach ($this->branchKeys() as $branch) {
|
||||
$before = $this->results($this->before, $branch);
|
||||
$after = $this->results($this->after, $branch);
|
||||
|
||||
foreach ($before as $testId => $entry) {
|
||||
if (! isset($after[$testId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (['status', 'time', 'assertions', 'message'] as $field) {
|
||||
if (($entry[$field] ?? null) !== ($after[$testId][$field] ?? null)) {
|
||||
$written++;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result entries that appeared.
|
||||
*/
|
||||
public function added(): int
|
||||
{
|
||||
$added = 0;
|
||||
|
||||
foreach ($this->branchKeys() as $branch) {
|
||||
$added += count(array_diff(
|
||||
array_keys($this->results($this->after, $branch)),
|
||||
array_keys($this->results($this->before, $branch)),
|
||||
));
|
||||
}
|
||||
|
||||
return $added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result entries that were pruned.
|
||||
*/
|
||||
public function removed(): int
|
||||
{
|
||||
$removed = 0;
|
||||
|
||||
foreach ($this->branchKeys() as $branch) {
|
||||
$removed += count(array_diff(
|
||||
array_keys($this->results($this->before, $branch)),
|
||||
array_keys($this->results($this->after, $branch)),
|
||||
));
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline keys after the run — the headline signal for the
|
||||
* default-branch rows, where a phantom key is the defect.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function branchKeys(): array
|
||||
{
|
||||
return array_keys($this->baselines($this->after));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function branchKeysBefore(): array
|
||||
{
|
||||
return array_keys($this->baselines($this->before));
|
||||
}
|
||||
|
||||
public function branchKeysMoved(): bool
|
||||
{
|
||||
return $this->branchKeysBefore() !== $this->branchKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the named branch's baseline is byte-identical — how a row proves
|
||||
* the fallback is read-only.
|
||||
*/
|
||||
public function baselineUntouched(string $branch): bool
|
||||
{
|
||||
return ($this->baselines($this->before)[$branch] ?? null)
|
||||
=== ($this->baselines($this->after)[$branch] ?? null);
|
||||
}
|
||||
|
||||
public function shaMoved(): bool
|
||||
{
|
||||
return array_any($this->branchKeys(), fn (string $branch) => $this->baselineField($branch, 'sha', $this->before) !== $this->baselineField($branch, 'sha', $this->after));
|
||||
}
|
||||
|
||||
public function treeMoved(): bool
|
||||
{
|
||||
return array_any($this->branchKeys(), fn (string $branch) => $this->baselineField($branch, 'tree', $this->before) !== $this->baselineField($branch, 'tree', $this->after));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares edges by the file paths they resolve to, not by file id: ids are
|
||||
* an implementation detail that shifts whenever `files` is rebuilt in a
|
||||
* different order.
|
||||
*/
|
||||
public function edgesMoved(): bool
|
||||
{
|
||||
return $this->edgeSets($this->before) !== $this->edgeSets($this->after);
|
||||
}
|
||||
|
||||
public function filesMoved(): bool
|
||||
{
|
||||
return $this->section($this->before, 'files') !== $this->section($this->after, 'files');
|
||||
}
|
||||
|
||||
public function fingerprintMoved(): bool
|
||||
{
|
||||
return $this->section($this->before, 'fingerprint') !== $this->section($this->after, 'fingerprint');
|
||||
}
|
||||
|
||||
public function structureMoved(): bool
|
||||
{
|
||||
if ($this->edgesMoved()) {
|
||||
return true;
|
||||
}
|
||||
if ($this->filesMoved()) {
|
||||
return true;
|
||||
}
|
||||
if ($this->fingerprintMoved()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->branchKeysMoved();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing moved at all.
|
||||
*/
|
||||
public function isHardSuppressed(): bool
|
||||
{
|
||||
return $this->before === $this->after;
|
||||
}
|
||||
|
||||
/**
|
||||
* Results may have moved for tests that ran; nothing structural did.
|
||||
*/
|
||||
public function isResultsOnly(): bool
|
||||
{
|
||||
return ! $this->graphWasCreated()
|
||||
&& ! $this->graphWasDeleted()
|
||||
&& ! $this->structureMoved()
|
||||
&& ! $this->shaMoved()
|
||||
&& ! $this->treeMoved()
|
||||
&& $this->removed() === 0
|
||||
&& $this->added() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-line verdict, for failure messages.
|
||||
*/
|
||||
public function summary(): string
|
||||
{
|
||||
if ($this->graphWasCreated()) {
|
||||
return 'graph created';
|
||||
}
|
||||
|
||||
if ($this->graphWasDeleted()) {
|
||||
return 'graph deleted';
|
||||
}
|
||||
|
||||
$moved = [];
|
||||
|
||||
foreach (['edges', 'files', 'fingerprint'] as $section) {
|
||||
if ($this->{$section.'Moved'}()) {
|
||||
$moved[] = $section;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->branchKeysMoved()) {
|
||||
$moved[] = sprintf(
|
||||
'branchkeys(%s->%s)',
|
||||
implode('|', $this->branchKeysBefore()),
|
||||
implode('|', $this->branchKeys()),
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'w=%d +%d -%d %s%s%s',
|
||||
$this->writtenCount(),
|
||||
$this->added(),
|
||||
$this->removed(),
|
||||
$moved === [] ? 'struct:ok' : 'STRUCT:'.implode(',', $moved),
|
||||
$this->shaMoved() ? ' sha:changed' : '',
|
||||
$this->treeMoved() ? ' tree:changed' : '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $graph
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private function edgeSets(?array $graph): array
|
||||
{
|
||||
$files = $this->section($graph, 'files');
|
||||
$sets = [];
|
||||
|
||||
foreach ($this->section($graph, 'edges') as $test => $ids) {
|
||||
if (! is_string($test)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($ids)) {
|
||||
continue;
|
||||
}
|
||||
$paths = array_map(
|
||||
fn (mixed $id): string => is_int($id) && isset($files[$id]) && is_string($files[$id])
|
||||
? $files[$id]
|
||||
: '?'.json_encode($id),
|
||||
$ids,
|
||||
);
|
||||
|
||||
sort($paths);
|
||||
$sets[$test] = $paths;
|
||||
}
|
||||
|
||||
ksort($sets);
|
||||
|
||||
return $sets;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $graph
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function section(?array $graph, string $key): array
|
||||
{
|
||||
$section = $graph[$key] ?? null;
|
||||
|
||||
return is_array($section) ? $section : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $graph
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function baselines(?array $graph): array
|
||||
{
|
||||
$baselines = [];
|
||||
|
||||
foreach ($this->section($graph, 'baselines') as $branch => $baseline) {
|
||||
if (is_string($branch)) {
|
||||
$baselines[$branch] = $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
return $baselines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $graph
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function results(?array $graph, string $branch): array
|
||||
{
|
||||
$results = $this->baselines($graph)[$branch]['results'] ?? null;
|
||||
|
||||
if (! is_array($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
|
||||
foreach ($results as $testId => $entry) {
|
||||
if (is_string($testId) && is_array($entry)) {
|
||||
$entries[$testId] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $graph
|
||||
*/
|
||||
private function baselineField(string $branch, string $field, ?array $graph): mixed
|
||||
{
|
||||
return $this->baselines($graph)[$branch][$field] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Fixtures\Tia;
|
||||
|
||||
/**
|
||||
* The outcome of one `pest` invocation against a fixture project.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class PestResult
|
||||
{
|
||||
/**
|
||||
* The run's output, with the terminal's escape sequences taken back out.
|
||||
*/
|
||||
public string $output;
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function __construct(
|
||||
public array $arguments,
|
||||
string $output,
|
||||
public int $exitCode,
|
||||
) {
|
||||
$this->output = (string) preg_replace([
|
||||
'#\x1b[[][^A-Za-z]*[A-Za-z]#', // colours, cursor moves
|
||||
'#\x1b\]8;[^\x1b\x07]*(?:\x1b\\\\|\x07)#', // hyperlinks
|
||||
], '', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whose cached result was replayed instead of executed.
|
||||
*/
|
||||
public function replayed(): int
|
||||
{
|
||||
return $this->recapFragment('replayed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that ran because the graph held nothing for them — the count that
|
||||
* betrays a fallback which never resolved.
|
||||
*/
|
||||
public function uncached(): int
|
||||
{
|
||||
return $this->recapFragment('uncached');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that ran because a file they depend on changed.
|
||||
*/
|
||||
public function affected(): int
|
||||
{
|
||||
return $this->recapFragment('affected');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Tests:` summary line, without its label or leading whitespace.
|
||||
*/
|
||||
public function tally(): string
|
||||
{
|
||||
if (preg_match('/^\s*Tests:\s+(.+)$/m', $this->output, $matches) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
public function contains(string $needle): bool
|
||||
{
|
||||
return str_contains($this->output, $needle);
|
||||
}
|
||||
|
||||
/**
|
||||
* A description of the run, for failure messages that would otherwise say
|
||||
* only that 0 !== 6.
|
||||
*/
|
||||
public function describe(): string
|
||||
{
|
||||
return sprintf(
|
||||
"pest %s exited %d:\n%s",
|
||||
implode(' ', $this->arguments),
|
||||
$this->exitCode,
|
||||
$this->output,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read off the `Tests:` line rather than the whole output: the TIA headline
|
||||
* counts affected *files*, and matching that instead would be a quietly
|
||||
* wrong number.
|
||||
*/
|
||||
private function recapFragment(string $label): int
|
||||
{
|
||||
if (preg_match('/(\d+) '.preg_quote($label, '/').'/', $this->tally(), $matches) !== 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $matches[1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Fixtures\Tia;
|
||||
|
||||
use FilesystemIterator;
|
||||
use Pest\Factories\TestCaseFactory;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\ChangedFiles;
|
||||
use Pest\Plugins\Tia\FileState;
|
||||
use Pest\Plugins\Tia\Fingerprint;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
use Pest\Plugins\Tia\Storage;
|
||||
use Pest\Support\Str;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* A throwaway Pest project the TIA scenario tests drive.
|
||||
*
|
||||
* Two facts about Pest shape everything here:
|
||||
*
|
||||
* 1. `bin/pest` derives the project root from **the autoloader it finds**, not
|
||||
* from the working directory — `dirname($autoloadPath, 2)`. So the project
|
||||
* owns a real `vendor/autoload.php` and a real copy of `bin/pest` at the path
|
||||
* a composer install would have put them. A symlinked `vendor` would resolve
|
||||
* `__DIR__` straight back to the Pest repository, and every scenario would
|
||||
* silently measure the wrong project.
|
||||
* 2. TIA cannot *record* without pcov or Xdebug, and CI has neither. So a
|
||||
* scenario never records: {@see self::seed()} writes the graph a recording
|
||||
* run would have written, and the run under test exercises the read path.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Project
|
||||
{
|
||||
/**
|
||||
* Test file → the source files a recording run would have linked it to. The
|
||||
* self-edge every test file gets is added on top of these.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
public const array EDGES = [
|
||||
'tests/Unit/CalculatorTest.php' => ['app/Calculator.php'],
|
||||
'tests/Unit/GreeterTest.php' => ['app/Greeter.php'],
|
||||
'tests/Feature/CoversCalculatorTest.php' => ['app/Calculator.php'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Test file → the descriptions it declares, in declaration order.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
public const array TESTS = [
|
||||
'tests/Unit/CalculatorTest.php' => ['adds two numbers', 'subtracts two numbers'],
|
||||
'tests/Unit/GreeterTest.php' => ['greets a person', 'greets the world'],
|
||||
'tests/Feature/CoversCalculatorTest.php' => ['adds within a feature test', 'subtracts within a feature test'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Every test in the fixture suite.
|
||||
*/
|
||||
public const int TOTAL_TESTS = 6;
|
||||
|
||||
/**
|
||||
* Every project scaffolded so far, so a row cannot leak one by failing
|
||||
* before its own cleanup.
|
||||
*
|
||||
* @var array<int, self>
|
||||
*/
|
||||
private static array $created = [];
|
||||
|
||||
private ?GitRepo $repo = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $snapshot = null;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $extraPaths = [];
|
||||
|
||||
/**
|
||||
* The root whose graph this project reads and writes.
|
||||
*
|
||||
* Its own, except where a row seeds a worktree: a worktree's `.git` is a
|
||||
* file rather than a directory, so {@see Storage} cannot read the remote
|
||||
* from it and resolves a storage key of its own.
|
||||
*/
|
||||
private string $graphRoot;
|
||||
|
||||
private function __construct(public readonly string $path)
|
||||
{
|
||||
$this->graphRoot = $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaffolds a project whose default branch is `$branch`, and hands it back
|
||||
* checked out there.
|
||||
*
|
||||
* The repository is realistic on purpose: it has an `origin`, and an
|
||||
* `origin/HEAD` naming `$branch`, which is what a checkout of a real project
|
||||
* looks like and what the default branch is autodetected from.
|
||||
*/
|
||||
public static function make(string $branch = 'master', ?string $overlay = null): self
|
||||
{
|
||||
$project = self::scaffold($overlay);
|
||||
|
||||
$project->git()->init($branch);
|
||||
$project->git()->addOrigin();
|
||||
$project->git()->setOriginHead($branch);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys every project scaffolded so far. Belongs in an `afterEach`.
|
||||
*/
|
||||
public static function destroyAll(): void
|
||||
{
|
||||
while (self::$created !== []) {
|
||||
array_pop(self::$created)->destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A project that is not a git repository at all — for the rows that assert
|
||||
* TIA still demands git, and that a plain run does not care.
|
||||
*
|
||||
* Lives in the system temp directory, so it is outside any enclosing
|
||||
* repository, and owns a real `vendor` rather than a symlinked one, so its
|
||||
* baseline key cannot collide with another fixture's.
|
||||
*/
|
||||
public static function withoutGit(?string $overlay = null): self
|
||||
{
|
||||
return self::scaffold($overlay);
|
||||
}
|
||||
|
||||
public function git(): GitRepo
|
||||
{
|
||||
return $this->repo ??= new GitRepo($this->path);
|
||||
}
|
||||
|
||||
public function path(string $relative = ''): string
|
||||
{
|
||||
return $relative === '' ? $this->path : $this->path.DIRECTORY_SEPARATOR.$relative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites a file in the project.
|
||||
*
|
||||
* Edits must be semantic: TIA hashes PHP at the AST level, so a
|
||||
* comment-only change is not a change at all.
|
||||
*/
|
||||
public function write(string $relative, string $contents): void
|
||||
{
|
||||
$path = $this->path($relative);
|
||||
$directory = dirname($path);
|
||||
|
||||
if (! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory)) {
|
||||
throw new RuntimeException(sprintf('Unable to create [%s].', $directory));
|
||||
}
|
||||
|
||||
if (file_put_contents($path, $contents) === false) {
|
||||
throw new RuntimeException(sprintf('Unable to write [%s].', $path));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a worktree for a new branch, scaffolded so `pest` can run in it, and
|
||||
* returns its path.
|
||||
*
|
||||
* The graph is shared with the main checkout, which is the whole point: both
|
||||
* resolve the same storage key, because {@see Storage} prefers the `origin`
|
||||
* identity over the path.
|
||||
*/
|
||||
public function worktree(string $branch): string
|
||||
{
|
||||
$path = $this->path.'-worktree-'.preg_replace('/[^a-z0-9]+/i', '-', $branch);
|
||||
|
||||
$this->git()->worktree($path, $branch);
|
||||
$this->scaffoldVendor($path);
|
||||
$this->extraPaths[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `pest` in the project and returns what happened.
|
||||
*/
|
||||
public function pest(string ...$arguments): PestResult
|
||||
{
|
||||
return $this->pestIn($this->path, ...$arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `pest` in `$directory` — a worktree, say — against this project's
|
||||
* graph.
|
||||
*/
|
||||
public function pestIn(string $directory, string ...$arguments): PestResult
|
||||
{
|
||||
return $this->pestWithEnvironment($directory, [], ...$arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $environment
|
||||
*/
|
||||
public function pestWithEnvironment(string $directory, array $environment, string ...$arguments): PestResult
|
||||
{
|
||||
$process = new Process(
|
||||
[PHP_BINARY, $directory.'/vendor/pestphp/pest/bin/pest', ...$arguments],
|
||||
$directory,
|
||||
[
|
||||
...GitRepo::ENV,
|
||||
'COLLISION_PRINTER' => 'DefaultPrinter',
|
||||
'COLLISION_IGNORE_DURATION' => 'true',
|
||||
'PARATEST' => '0',
|
||||
'PAO_DISABLE' => '1',
|
||||
'HOME' => $this->home(),
|
||||
...$environment,
|
||||
],
|
||||
);
|
||||
|
||||
$process->setTimeout(180.0);
|
||||
$process->run();
|
||||
|
||||
return new PestResult(
|
||||
array_values($arguments),
|
||||
$process->getOutput().$process->getErrorOutput(),
|
||||
(int) $process->getExitCode(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the graph a clean, green recording run on `$branch` would have
|
||||
* written, and remembers it as the snapshot the next {@see self::delta()}
|
||||
* compares against.
|
||||
*
|
||||
* Sentinelled by default: a row almost always wants to know which entries
|
||||
* were written, and only a real recording run's values can answer that.
|
||||
*
|
||||
* `$failing` names descriptions to record as failures — a clean green run
|
||||
* can never cache one, so a row that needs a cached failure to re-run has to
|
||||
* be handed it.
|
||||
*
|
||||
* @param array<int, string> $failing
|
||||
*/
|
||||
public function seed(string $branch, bool $sentinel = true, array $failing = []): void
|
||||
{
|
||||
$this->seedFor($this->path, $branch, $sentinel, $failing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the graph belonging to `$root` — a worktree, say, which resolves a
|
||||
* storage key of its own.
|
||||
*
|
||||
* @param array<int, string> $failing
|
||||
*/
|
||||
public function seedFor(string $root, string $branch, bool $sentinel = true, array $failing = []): void
|
||||
{
|
||||
$this->graphRoot = $root;
|
||||
|
||||
$changedFiles = new ChangedFiles($root);
|
||||
$sha = new GitRepo($root)->sha();
|
||||
|
||||
$graph = new Graph($root);
|
||||
$graph->setFingerprint(Fingerprint::compute($root));
|
||||
$graph->setRecordedAtSha($branch, $sha);
|
||||
|
||||
// Hashes the tree as it stands, so the run under test sees nothing as
|
||||
// changed — the same call the recording path makes.
|
||||
$graph->setLastRunTree($branch, $changedFiles->snapshotTree($changedFiles->since($sha) ?? []));
|
||||
|
||||
$graph->markKnownTestFiles(array_keys(self::EDGES));
|
||||
|
||||
foreach (self::EDGES as $testFile => $sourceFiles) {
|
||||
$graph->link($testFile, $testFile);
|
||||
|
||||
foreach ($sourceFiles as $sourceFile) {
|
||||
$graph->link($testFile, $sourceFile);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::TESTS as $testFile => $descriptions) {
|
||||
foreach ($descriptions as $description) {
|
||||
$failed = in_array($description, $failing, true);
|
||||
|
||||
$graph->setResult(
|
||||
$branch,
|
||||
self::testId($testFile, $description),
|
||||
$failed ? 7 : 0,
|
||||
$failed ? 'cached failure' : '',
|
||||
0.05,
|
||||
1,
|
||||
$testFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$json = $graph->encode();
|
||||
|
||||
if ($json === null) {
|
||||
throw new RuntimeException('Unable to encode the seeded graph.');
|
||||
}
|
||||
|
||||
if (! $this->state()->write(Tia::KEY_GRAPH, $json)) {
|
||||
throw new RuntimeException('Unable to persist the seeded graph.');
|
||||
}
|
||||
|
||||
$sentinel ? $this->sentinel() : $this->snapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* The id PHPUnit reports for a test in the fixture suite.
|
||||
*
|
||||
* Mirrors how Pest names generated test classes
|
||||
* ({@see TestCaseFactory}): a wrong id here shows up as
|
||||
* `0 replayed`, which every scenario asserts against.
|
||||
*/
|
||||
public static function testId(string $testFile, string $description): string
|
||||
{
|
||||
$basename = basename($testFile, '.php');
|
||||
$dotPosition = strpos($basename, '.');
|
||||
|
||||
if ($dotPosition !== false) {
|
||||
$basename = substr($basename, 0, $dotPosition);
|
||||
}
|
||||
|
||||
$relative = dirname(ucfirst($testFile)).DIRECTORY_SEPARATOR.$basename;
|
||||
|
||||
return 'P\\'.str_replace(DIRECTORY_SEPARATOR, '\\', $relative).'::'.Str::evaluable($description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Falsifies every cached value, so the next {@see self::delta()} can tell
|
||||
* "wrote the same values back" from "wrote nothing".
|
||||
*
|
||||
* `assertions` is only ever falsified where it is already non-zero: risky,
|
||||
* skipped and incomplete statuses are *derived* from "performed no
|
||||
* assertions", so patching those would rewrite the status on replay and
|
||||
* destroy the very discriminator this exists to provide.
|
||||
*/
|
||||
public function sentinel(): void
|
||||
{
|
||||
$graph = $this->graph();
|
||||
|
||||
if ($graph === null) {
|
||||
throw new RuntimeException('There is no graph to sentinel.');
|
||||
}
|
||||
|
||||
foreach ($graph['baselines'] ?? [] as $branch => $baseline) {
|
||||
foreach (array_keys($baseline['results'] ?? []) as $testId) {
|
||||
$graph['baselines'][$branch]['results'][$testId]['time'] = 9.999;
|
||||
|
||||
if ((int) ($baseline['results'][$testId]['assertions'] ?? 0) > 0) {
|
||||
$graph['baselines'][$branch]['results'][$testId]['assertions'] = 42;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->state()->write(Tia::KEY_GRAPH, (string) json_encode($graph, JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$this->snapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* The decoded graph, or `null` when there is none.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function graph(): ?array
|
||||
{
|
||||
$json = $this->state()->read(Tia::KEY_GRAPH);
|
||||
|
||||
if ($json === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$graph = json_decode($json, true);
|
||||
|
||||
return is_array($graph) ? $graph : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function branchKeys(): array
|
||||
{
|
||||
$baselines = $this->graph()['baselines'] ?? [];
|
||||
|
||||
return is_array($baselines) ? array_keys($baselines) : [];
|
||||
}
|
||||
|
||||
public function graphDir(): string
|
||||
{
|
||||
return $this->withHome(fn (): string => Storage::tempDir($this->graphRoot));
|
||||
}
|
||||
|
||||
public function graphExists(): bool
|
||||
{
|
||||
return is_file($this->graphDir().DIRECTORY_SEPARATOR.Tia::KEY_GRAPH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the graph as it stands now.
|
||||
*/
|
||||
public function snapshot(): void
|
||||
{
|
||||
$this->snapshot = $this->graph();
|
||||
}
|
||||
|
||||
/**
|
||||
* What has happened to the graph since the last snapshot.
|
||||
*/
|
||||
public function delta(): GraphDelta
|
||||
{
|
||||
return new GraphDelta($this->snapshot, $this->graph());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the `vendor` a composer install would have produced.
|
||||
*
|
||||
* Pest is mirrored in at `vendor/pestphp/pest` rather than pointed at,
|
||||
* because Pest locates things from where its own files sit:
|
||||
* `bin/pest` finds the project root by walking up from the autoloader it
|
||||
* loads, and a parallel run picks its worker binary — and with it the
|
||||
* worker's project root — from the directory the runner class was loaded
|
||||
* from. Deferring to the repository's copy would resolve both back to the
|
||||
* Pest repository, and every scenario would quietly measure that instead.
|
||||
*
|
||||
* Hardlinked where the filesystem allows it, so the mirror costs almost
|
||||
* nothing and can never drift from the working tree.
|
||||
*/
|
||||
public function scaffoldVendor(string $directory): void
|
||||
{
|
||||
$pestRoot = dirname(__DIR__, 3);
|
||||
$pest = $directory.'/vendor/pestphp/pest';
|
||||
|
||||
// `overrides`, `resources` and `stubs` come along because Pest loads
|
||||
// them relative to `src` — the same list `BootExcludeList` walks.
|
||||
foreach (['src', 'overrides', 'resources', 'stubs'] as $tree) {
|
||||
self::mirror($pestRoot.'/'.$tree, $pest.'/'.$tree);
|
||||
}
|
||||
|
||||
foreach (['pest', 'worker.php'] as $binary) {
|
||||
self::mirror($pestRoot.'/bin/'.$binary, $pest.'/bin/'.$binary);
|
||||
}
|
||||
|
||||
self::mirror($pestRoot.'/composer.json', $pest.'/composer.json');
|
||||
|
||||
// Pest's own autoloader, with the mirrored copy taking precedence: the
|
||||
// repository's `vendor` supplies PHPUnit, Symfony and the plugin
|
||||
// packages, none of which care where they are loaded from.
|
||||
file_put_contents($directory.'/vendor/autoload.php', sprintf(
|
||||
"<?php\n\n\$loader = require %s;\n\$loader->addPsr4('Pest\\\\', __DIR__.'/pestphp/pest/src', true);\n\nreturn \$loader;\n",
|
||||
var_export($pestRoot.'/vendor/autoload.php', true),
|
||||
));
|
||||
|
||||
// Invoking the binary directly skips composer's bin proxy, which is
|
||||
// what would otherwise define `$GLOBALS['_composer_bin_dir']`. Without
|
||||
// it `Pest\Plugin\Loader` looks for `vendor/bin/../pest-plugins.json`
|
||||
// relative to the working directory — so that is where the plugin list
|
||||
// goes, and mirroring the repository's keeps it in step with
|
||||
// composer.json. `vendor/bin` has to exist for the `..` in that path to
|
||||
// resolve, empty though it is.
|
||||
if (! is_dir($directory.'/vendor/bin') && ! @mkdir($directory.'/vendor/bin', 0755, true)) {
|
||||
throw new RuntimeException(sprintf('Unable to create [%s].', $directory.'/vendor/bin'));
|
||||
}
|
||||
|
||||
self::mirror($pestRoot.'/vendor/pest-plugins.json', $directory.'/vendor/pest-plugins.json');
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
foreach ([...$this->extraPaths, $this->path] as $path) {
|
||||
$this->remove($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function home(): string
|
||||
{
|
||||
return $this->path.DIRECTORY_SEPARATOR.'.home';
|
||||
}
|
||||
|
||||
private function state(): FileState
|
||||
{
|
||||
return new FileState($this->graphDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* `Storage` reads `HOME` from the environment, so the graph lands inside the
|
||||
* throwaway project instead of the developer's real `~/.pest`.
|
||||
*
|
||||
* @template TReturn
|
||||
*
|
||||
* @param callable(): TReturn $callback
|
||||
* @return TReturn
|
||||
*/
|
||||
private function withHome(callable $callback): mixed
|
||||
{
|
||||
$original = getenv('HOME');
|
||||
|
||||
putenv('HOME='.$this->home());
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
putenv($original === false ? 'HOME' : 'HOME='.$original);
|
||||
}
|
||||
}
|
||||
|
||||
private static function scaffold(?string $overlay): self
|
||||
{
|
||||
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(8));
|
||||
|
||||
if (! @mkdir($path, 0755, true) && ! is_dir($path)) {
|
||||
throw new RuntimeException(sprintf('Unable to create [%s].', $path));
|
||||
}
|
||||
|
||||
// Realpathed because Pest realpaths its own project root, and macOS
|
||||
// hands out a symlinked temp directory.
|
||||
$real = realpath($path);
|
||||
|
||||
$project = new self($real === false ? $path : $real);
|
||||
|
||||
self::$created[] = $project;
|
||||
|
||||
self::copy(__DIR__.'/app', $project->path);
|
||||
|
||||
if ($overlay !== null) {
|
||||
self::copy(__DIR__.'/overlays/'.$overlay, $project->path);
|
||||
}
|
||||
|
||||
// `ChangedFiles` asks git what changed, so anything the scaffold writes
|
||||
// but the project does not own has to be invisible to it.
|
||||
$project->write('.gitignore', implode("\n", ['/vendor/', '/.home/', '/.phpunit.cache/', '']));
|
||||
|
||||
$project->scaffoldVendor($project->path);
|
||||
@mkdir($project->home(), 0755, true);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a file or directory, hardlinking where the filesystem allows it
|
||||
* and copying where it does not.
|
||||
*/
|
||||
private static function mirror(string $from, string $to): void
|
||||
{
|
||||
if (is_dir($from)) {
|
||||
foreach (self::contentsOf($from) as $path) {
|
||||
self::mirror($path->getPathname(), $to.DIRECTORY_SEPARATOR.substr($path->getPathname(), strlen($from) + 1));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$directory = dirname($to);
|
||||
|
||||
if (! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory)) {
|
||||
throw new RuntimeException(sprintf('Unable to create [%s].', $directory));
|
||||
}
|
||||
|
||||
if (@link($from, $to) || @copy($from, $to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException(sprintf('Unable to mirror [%s] into [%s].', $from, $to));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<\SplFileInfo>
|
||||
*/
|
||||
private static function contentsOf(string $directory): iterable
|
||||
{
|
||||
$paths = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST,
|
||||
);
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (! $path->isDir()) {
|
||||
yield $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function copy(string $from, string $to): void
|
||||
{
|
||||
$paths = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($from, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST,
|
||||
);
|
||||
|
||||
foreach ($paths as $path) {
|
||||
$target = $to.DIRECTORY_SEPARATOR.substr($path->getPathname(), strlen($from) + 1);
|
||||
|
||||
if ($path->isDir()) {
|
||||
if (! is_dir($target) && ! @mkdir($target, 0755, true) && ! is_dir($target)) {
|
||||
throw new RuntimeException(sprintf('Unable to create [%s].', $target));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_dir(dirname($target)) && ! @mkdir(dirname($target), 0755, true) && ! is_dir(dirname($target))) {
|
||||
throw new RuntimeException(sprintf('Unable to create [%s].', dirname($target)));
|
||||
}
|
||||
|
||||
if (! @copy($path->getPathname(), $target)) {
|
||||
throw new RuntimeException(sprintf('Unable to copy [%s].', $path->getPathname()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function remove(string $path): void
|
||||
{
|
||||
if (! is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$paths = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
|
||||
foreach ($paths as $entry) {
|
||||
$entry->isDir() && ! $entry->isLink() ? @rmdir($entry->getPathname()) : @unlink($entry->getPathname());
|
||||
}
|
||||
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fixture\App;
|
||||
|
||||
final class Calculator
|
||||
{
|
||||
public function add(int $a, int $b): int
|
||||
{
|
||||
return $a + $b;
|
||||
}
|
||||
|
||||
public function subtract(int $a, int $b): int
|
||||
{
|
||||
return $a - $b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fixture\App;
|
||||
|
||||
final class Greeter
|
||||
{
|
||||
public function greet(string $name): string
|
||||
{
|
||||
return sprintf('Hello, %s!', $name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "pest/tia-fixture",
|
||||
"description": "Throwaway project the TIA scenario tests scaffold into a temp directory.",
|
||||
"license": "MIT",
|
||||
"require": {},
|
||||
"config": {
|
||||
"lock": true
|
||||
}
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"_readme": [
|
||||
"Hand-written: the fixture project installs nothing. It exists so the TIA",
|
||||
"fingerprint has a composer.lock to hash, the way a real project does."
|
||||
],
|
||||
"content-hash": "0000000000000000000000000000000000",
|
||||
"packages": [],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
colors="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="false"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="default">
|
||||
<directory suffix="Test.php">./tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">./app</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Fixture\App\Calculator;
|
||||
|
||||
// A second test file over the same source file, so a `Calculator` edit narrows
|
||||
// to two of the three test files rather than to one.
|
||||
test('adds within a feature test', function (): void {
|
||||
expect((new Calculator)->add(10, 5))->toBe(15);
|
||||
});
|
||||
|
||||
test('subtracts within a feature test', function (): void {
|
||||
expect((new Calculator)->subtract(10, 5))->toBe(5);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// The fixture project has no composer autoloader of its own — its `vendor/`
|
||||
// holds nothing but a shim pointing back at Pest's. Requiring the two classes
|
||||
// here is enough for every test file in the suite.
|
||||
require_once __DIR__.'/../app/Calculator.php';
|
||||
require_once __DIR__.'/../app/Greeter.php';
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Fixture\App\Calculator;
|
||||
|
||||
// `test()` rather than `it()`: the harness seeds results by test id, and `it()`
|
||||
// would prefix every description with `it `, leaving Project::TESTS a step away
|
||||
// from what is written here.
|
||||
test('adds two numbers', function (): void {
|
||||
expect((new Calculator)->add(1, 2))->toBe(3);
|
||||
});
|
||||
|
||||
test('subtracts two numbers', function (): void {
|
||||
expect((new Calculator)->subtract(3, 1))->toBe(2);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Fixture\App\Greeter;
|
||||
|
||||
test('greets a person', function (): void {
|
||||
expect((new Greeter)->greet('Nuno'))->toBe('Hello, Nuno!');
|
||||
});
|
||||
|
||||
test('greets the world', function (): void {
|
||||
expect((new Greeter)->greet('world'))->toBe('Hello, world!');
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../app/Calculator.php';
|
||||
require_once __DIR__.'/../app/Greeter.php';
|
||||
|
||||
// Declared default branch. Wins over whatever the repository autodetects — the
|
||||
// escape hatch for a checkout with no `origin/HEAD` and a misleading
|
||||
// `init.defaultBranch`.
|
||||
pest()->tia()->defaultBranch('master');
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__.'/../app/Calculator.php';
|
||||
require_once __DIR__.'/../app/Greeter.php';
|
||||
|
||||
// A branch the repository does not have. Accepted as configured — a name that
|
||||
// resolves to no baseline degrades to a full run, which is safe.
|
||||
pest()->tia()->defaultBranch('nope');
|
||||
@@ -19,6 +19,10 @@ pest()->in('PHPUnit/GlobPatternTests/SubFolder2/*AsPattern.php')->use(CustomTest
|
||||
|
||||
pest()->in('Visual')->group('integration');
|
||||
|
||||
// Every row scaffolds a throwaway project and runs `pest` in it as a
|
||||
// subprocess, which is far too slow for the unit suite.
|
||||
pest()->in('Features/Tia')->group('integration');
|
||||
|
||||
// NOTE: global test value container to be mutated and checked across files, as needed
|
||||
$_SERVER['globalHook'] = (object) ['calls' => (object) ['beforeAll' => 0, 'afterAll' => 0]];
|
||||
|
||||
|
||||
@@ -66,8 +66,26 @@ describe('applyMigrationChanges()', function (): void {
|
||||
});
|
||||
|
||||
describe('rerun tracking', function (): void {
|
||||
beforeEach(function (): void {
|
||||
// `hasUnlocatedTestsToRerun()` stats each recorded file to tell a
|
||||
// deleted test apart from a live one, so the files have to exist.
|
||||
$this->projectRoot = sys_get_temp_dir().'/pest-tia-rerun-'.bin2hex(random_bytes(4));
|
||||
mkdir($this->projectRoot.'/tests/Feature', 0755, true);
|
||||
|
||||
touch($this->projectRoot.'/tests/Feature/FooTest.php');
|
||||
touch($this->projectRoot.'/tests/Feature/BarTest.php');
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
@unlink($this->projectRoot.'/tests/Feature/FooTest.php');
|
||||
@unlink($this->projectRoot.'/tests/Feature/BarTest.php');
|
||||
@rmdir($this->projectRoot.'/tests/Feature');
|
||||
@rmdir($this->projectRoot.'/tests');
|
||||
@rmdir($this->projectRoot);
|
||||
});
|
||||
|
||||
it('reruns cached failures via their file', function (): void {
|
||||
$graph = new Graph(sys_get_temp_dir());
|
||||
$graph = new Graph($this->projectRoot);
|
||||
$graph->setResult('main', 'Tests\FooTest::it fails', 7, 'boom', 0.1, 1, 'tests/Feature/FooTest.php');
|
||||
$graph->setResult('main', 'Tests\BarTest::it passes', 0, '', 0.1, 1, 'tests/Feature/BarTest.php');
|
||||
|
||||
@@ -76,7 +94,7 @@ describe('rerun tracking', function (): void {
|
||||
});
|
||||
|
||||
it('flags cached failures whose file is unknown', function (): void {
|
||||
$graph = new Graph(sys_get_temp_dir());
|
||||
$graph = new Graph($this->projectRoot);
|
||||
$graph->setResult('main', 'Tests\EvalTest::it fails', 7, 'boom', 0.1, 1);
|
||||
|
||||
expect($graph->testFilesToRerun('main'))->toBeEmpty()
|
||||
|
||||
Reference in New Issue
Block a user