This commit is contained in:
nuno maduro
2026-08-06 22:05:15 +01:00
parent 09699847a2
commit b49ba062d5
19 changed files with 1589 additions and 41 deletions
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
/*
* Invariant 5 — writes land on the branch that ran and only there — and
* invariant 6 — nothing is unbounded — under every branch shape git allows.
*/
test('a branch name git allows is a branch key TIA can hold', function (string $branch): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo($branch, new: true);
$result = $project->pest('--tia');
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($result->uncached())->toBe(0, $result->describe())
->and($project->branchKeys())->toBe(['master', $branch])
->and($delta->baselineUntouched('master'))->toBeTrue($delta->summary());
})->with([
'slashes' => 'feature/deep/nesting',
'dots' => 'release.1.2.x',
'unicode' => 'feature-café-日本',
'digits' => '12345',
'underscores' => 'feature_x_y',
'very long' => 'feature-'.str_repeat('x', 180),
])->skipOnWindows();
test('a branch differing from the default only in case gets its own key', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('MASTER-2', new: true);
$result = $project->pest('--tia');
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($project->branchKeys())->toBe(['master', 'MASTER-2']);
})->skipOnWindows();
test('a branch that only lives on the remote keeps its baseline', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('remote-only', new: true);
$project->pest('--tia');
$project->git()->switchTo('master');
$project->git()->run(['update-ref', 'refs/remotes/origin/remote-only', 'HEAD']);
$project->git()->run(['branch', '-D', 'remote-only']);
$project->pest('--tia');
expect($project->branchKeys())->toBe(['master', 'remote-only']);
})->skipOnWindows();
test('a branch checked out in a worktree keeps its baseline', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('wt-branch', new: true);
$project->pest('--tia');
$project->git()->switchTo('master');
$project->git()->run(['worktree', 'add', '--quiet', $project->path().'-wt', 'wt-branch']);
$project->pest('--tia');
$project->git()->run(['worktree', 'remove', '--force', $project->path().'-wt']);
expect($project->branchKeys())->toBe(['master', 'wt-branch']);
})->skipOnWindows();
test('deleting many branches reclaims every one of their baselines', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
foreach (range(1, 4) as $index) {
$project->git()->switchTo('feature-'.$index, new: true);
$project->pest('--tia', ...$arguments);
}
expect($project->branchKeys())->toHaveCount(5);
$project->git()->switchTo('master');
foreach (range(1, 4) as $index) {
$project->git()->run(['branch', '-D', 'feature-'.$index]);
}
$project->pest('--tia', ...$arguments);
expect($project->branchKeys())->toBe(['master']);
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a narrowed run does not reclaim anything', 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()->run(['branch', '-D', 'feature-x']);
$project->snapshot();
$project->pest('--tia', '--filter=adds two numbers');
$delta = $project->delta();
expect($project->branchKeys())->toBe(['master', 'feature-x'])
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->skipOnWindows();
test('a detached HEAD does not reclaim anything either', 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()->run(['branch', '-D', 'feature-x']);
$project->git()->detach();
$project->snapshot();
$project->pest('--tia');
$delta = $project->delta();
expect($project->branchKeys())->toBe(['master', 'feature-x'])
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->skipOnWindows();
test('the default branch baseline survives every branch that comes and goes', 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()->run(['branch', '-D', 'feature-x']);
$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->added())->toBe(0, $delta->summary())
->and($delta->removed())->toBe(0, $delta->summary())
->and($project->graph()['baselines']['master']['results'])->toHaveCount(Project::TOTAL_TESTS);
})->skipOnWindows();
@@ -75,10 +75,13 @@ test('filtered mode falls back to a full replay when a cached failure cannot be
$project->seed('master', failing: ['adds two numbers']);
// A path this project cannot address at all — recorded on another machine.
// A path that merely no longer exists is a *deleted* test, not a lost one,
// and widening the run would not find it either; see StateReclamation.
$project->mutateGraph(function (array $graph): array {
$testId = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$testId]['file'] = 'tests/Unit/DeletedTest.php';
$graph['baselines']['master']['results'][$testId]['file'] = '/build/agent/tests/Unit/DeletedTest.php';
return $graph;
});
+226
View File
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
/*
* Invariant 7 — a hostile state dir cannot break a run. Whatever is in
* graph.json, the suite still runs and exits on the tests' merit.
*/
test('a graph mangled beyond use still lets the suite run', function (string $contents): void {
$project = Project::make('master');
$project->seed('master');
file_put_contents($project->graphDir().'/graph.json', $contents);
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with([
'empty' => '',
'truncated' => '{"schema":1,"files":["app/Calculator.php"],"edg',
'not json' => '{not json',
'json scalar' => '"just a string"',
'json list' => '[1,2,3]',
'json null' => 'null',
'empty object' => '{}',
'nul bytes' => "\0\0\0\0",
])->skipOnWindows();
test('a graph whose shape is wrong everywhere is repaired rather than trusted', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id] = 'nope';
$graph['baselines']['master']['results'][7] = ['status' => 0, 'message' => '', 'time' => 0.1];
$graph['baselines']['master']['tree'] = 'nope';
$graph['baselines']['master']['sha'] = 42;
$graph['baselines'][''] = ['sha' => null, 'tree' => [], 'results' => []];
$graph['baselines']['broken'] = 'nope';
$graph['edges']['tests/Unit/GreeterTest.php'] = 'nope';
$graph['edges'][''] = [0];
$graph['files'][] = ['nested'];
return $graph;
});
$result = $project->pest('--tia', ...$arguments);
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->not->toContain('TypeError')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($project->branchKeys())->toBe(['master']);
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a cached status this build cannot interpret is re-run, not replayed', function (int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->uncached())->toBe(1, $result->describe());
})->with([
'below the range' => -1,
'one past the range' => 9,
'far past the range' => 99,
'huge' => PHP_INT_MAX,
])->skipOnWindows();
test('a cached status with no replay of its own does not fail the run', function (int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
$graph['baselines']['master']['results'][$id]['message'] = 'cached detail';
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with(['notice' => 3, 'deprecation' => 4, 'warning' => 6])->skipOnWindows();
test('a cached skip or todo replays with its message intact', function (int $status, string $tally): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
$graph['baselines']['master']['results'][$id]['message'] = 'a recorded reason';
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain($tally)
->and($result->output)->toContain('a recorded reason')
->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe());
})->with([
'skipped' => [1, '1 skipped'],
'incomplete' => [2, '1 incomplete'],
])->skipOnWindows();
test('a cached failure with a multi-line message re-runs rather than replaying the text', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = 7;
$graph['baselines']['master']['results'][$id]['message'] = "line one\nline two\nline three";
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->uncached())->toBe(1, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->skipOnWindows();
test('a result pointing outside the project is not addressable and widens the run', function (): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
$project->mutateGraph(function (array $graph): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['file'] = '/build/agent/tests/Unit/CalculatorTest.php';
return $graph;
});
$result = $project->pest('--tia', '--filtered');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->toContain('could not be located on disk')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->skipOnWindows();
test('an edge pointing at a file id that does not exist is ignored', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$graph['edges']['tests/Unit/CalculatorTest.php'] = [0, 999, -5];
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe());
})->skipOnWindows();
test('a graph from a schema this build does not know is rebuilt, not read', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$graph['schema'] = 2;
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->replayed())->toBe(0, $result->describe());
})->skipOnWindows();
test('graph.json being a directory does not stop the run', function (): void {
$project = Project::make('master');
$project->seed('master');
unlink($project->graphDir().'/graph.json');
mkdir($project->graphDir().'/graph.json');
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->skipOnWindows();
test('a state dir it cannot write to still replays', function (): void {
$project = Project::make('master');
$project->seed('master');
chmod($project->graphDir(), 0500);
try {
$result = $project->pest('--tia');
} finally {
chmod($project->graphDir(), 0700);
}
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe());
})->skipOnWindows();
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
/*
* A test that triggers a notice, deprecation or warning is reported by PHPUnit
* as passed, and emits Passed. Recording it as a plain success made the cache
* hide the issue: a later run under --fail-on-* came back green where a fresh
* run failed. Invariant 3 — replay is faithful — at its most dangerous.
*/
function tiaTriggering(string $call): string
{
return <<<PHP
<?php
declare(strict_types=1);
use Fixture\App\Calculator;
test('adds two numbers', function (): void {
{$call}
expect((new Calculator)->add(1, 2))->toBe(3);
});
test('subtracts two numbers', function (): void {
expect((new Calculator)->subtract(3, 1))->toBe(2);
});
PHP;
}
test('a triggered issue is recorded as itself, not as a pass', function (array $arguments, string $call, int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', tiaTriggering($call));
$project->git()->commit('trigger an issue');
$project->pest('--tia', ...$arguments);
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
expect($project->graph()['baselines']['master']['results'][$id]['status'])->toBe($status);
})->with(Project::SEQUENTIAL_AND_PARALLEL)->with([
'deprecation' => ["trigger_error('legacy adder', E_USER_DEPRECATED);", 4],
'notice' => ["trigger_error('a notice', E_USER_NOTICE);", 3],
'warning' => ["trigger_error('a warning', E_USER_WARNING);", 6],
])->skipOnWindows();
test('a cached deprecation still fails the run that asked to fail on one', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', tiaTriggering("trigger_error('legacy adder', E_USER_DEPRECATED);"));
$project->git()->commit('trigger a deprecation');
$project->pest('--tia', ...$arguments);
$result = $project->pest('--tia', '--fail-on-deprecation', ...$arguments);
expect($result->exitCode)->toBe(1, $result->describe())
->and($result->tally())->toContain('1 deprecated')
->and($result->uncached())->toBe(1, $result->describe());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('replaying a cached issue does not downgrade it to a pass', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', tiaTriggering("trigger_error('legacy adder', E_USER_DEPRECATED);"));
$project->git()->commit('trigger a deprecation');
$project->pest('--tia', ...$arguments);
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
foreach (range(1, 3) as $ignored) {
$project->pest('--tia', ...$arguments);
expect($project->graph()['baselines']['master']['results'][$id]['status'])->toBe(4);
}
$result = $project->pest('--tia', '--fail-on-deprecation', ...$arguments);
expect($result->exitCode)->toBe(1, $result->describe());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a failure outranks an issue triggered on the way to it', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', str_replace(
'toBe(3)',
'toBe(999)',
tiaTriggering("trigger_error('legacy adder', E_USER_DEPRECATED);"),
));
$project->git()->commit('an issue then a failure');
$result = $project->pest('--tia');
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
expect($result->exitCode)->not->toBe(0)
->and($project->graph()['baselines']['master']['results'][$id]['status'])->toBe(7);
})->skipOnWindows();
test('a skip outranks an issue triggered on the way to it', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', str_replace(
'expect((new Calculator)->add(1, 2))->toBe(3);',
"\$this->markTestSkipped('not today');",
tiaTriggering("trigger_error('legacy adder', E_USER_DEPRECATED);"),
));
$project->git()->commit('an issue then a skip');
$project->pest('--tia');
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
expect($project->graph()['baselines']['master']['results'][$id]['status'])->toBe(1);
})->skipOnWindows();
test('a suppressed issue is not recorded', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', tiaTriggering("@trigger_error('quiet', E_USER_DEPRECATED);"));
$project->git()->commit('a suppressed deprecation');
$project->pest('--tia');
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
expect($project->graph()['baselines']['master']['results'][$id]['status'])->toBe(0);
})->skipOnWindows();
+345
View File
@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
use Tests\Fixtures\Tia\Project;
afterEach(function (): void {
Project::destroyAll();
});
test('a detached HEAD does not purge the graph on structural drift', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->detach();
$project->write('composer.lock', (string) json_encode([
'content-hash' => 'drifted',
'packages' => [],
'packages-dev' => [],
]));
$result = $project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->toBe(0, $result->describe())
->and($project->graphExists())->toBeTrue('the detached run deleted graph.json')
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a detached HEAD does not purge the graph with --fresh either', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->detach();
$result = $project->pest('--tia', '--fresh', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->toBe(0, $result->describe())
->and($project->graphExists())->toBeTrue('the detached --fresh run deleted graph.json')
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a detached HEAD leaves an unreadable graph for a checkout that can rebuild it', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->detach();
file_put_contents($project->graphDir().'/graph.json', '{not json');
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and(file_get_contents($project->graphDir().'/graph.json'))->toBe('{not json');
})->skipOnWindows();
test('a cached failure whose test file was deleted stops widening later runs', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
unlink($project->path('tests/Unit/CalculatorTest.php'));
$project->git()->commit('drop CalculatorTest');
$project->pest('--tia', '--filtered', ...$arguments);
$project->snapshot();
$second = $project->pest('--tia', '--filtered', ...$arguments);
$delta = $project->delta();
expect($second->output)->not->toContain('could not be located on disk')
->and($second->output)->toContain('No affected tests found')
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a complete run reclaims the entry and the edge of a deleted test file', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
unlink($project->path('tests/Unit/CalculatorTest.php'));
$project->git()->commit('drop CalculatorTest');
$project->pest('--tia', ...$arguments);
$graph = $project->graph();
expect($graph['edges'] ?? [])->not->toHaveKey('tests/Unit/CalculatorTest.php')
->and(array_column($graph['baselines']['master']['results'] ?? [], 'file'))
->not->toContain('tests/Unit/CalculatorTest.php');
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a pruned result does not come back from the fallback', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
$project->git()->switchTo('feature-x', new: true);
$project->write('tests/Unit/CalculatorTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Calculator;
test('adds two numbers, renamed', function (): void {
expect((new Calculator)->add(1, 2))->toBe(3);
});
test('subtracts two numbers', function (): void {
expect((new Calculator)->subtract(3, 1))->toBe(2);
});
PHP);
$project->git()->commit('rename the test on the branch');
$project->pest('--tia', '--filtered', ...$arguments);
$second = $project->pest('--tia', '--filtered', ...$arguments);
expect($second->output)->not->toContain('previously unsuccessful')
->and($second->output)->toContain('No affected tests found');
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('the fallback still reaches a branch that has never run a test file', function (): void {
$project = Project::make('master');
$project->seed('master', failing: ['adds two numbers']);
$project->git()->switchTo('feature-x', new: true);
// A narrowed run mints the branch key holding only the Greeter entries, so
// the layering must still serve master's cached failure for the Calculator.
$project->pest('--filter=greets a person');
$result = $project->pest('--tia', '--filtered');
expect($result->output)->toContain('previously unsuccessful')
->and($result->affected())->toBe(2, $result->describe());
})->skipOnWindows();
test('a branch that git no longer knows loses its baseline', function (): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia');
expect($project->branchKeys())->toBe(['master', 'feature-x']);
$project->git()->switchTo('master');
$project->git()->run(['branch', '-D', 'feature-x']);
$project->pest('--tia');
expect($project->branchKeys())->toBe(['master']);
})->skipOnWindows();
test('an unknown cached status is re-run rather than replayed as a failure', function (int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($result->uncached())->toBe(1, $result->describe());
})->with(['unknown' => -1, 'future' => 9, 'garbage' => 99])->skipOnWindows();
test('a cached notice, deprecation or warning does not replay as a failure', function (int $status): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph) use ($status): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id]['status'] = $status;
$graph['baselines']['master']['results'][$id]['message'] = 'cached detail';
return $graph;
});
$result = $project->pest('--tia');
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with(['notice' => 3, 'deprecation' => 4, 'warning' => 6])->skipOnWindows();
test('a malformed baseline entry cannot break the run', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$id = Project::testId('tests/Unit/CalculatorTest.php', 'adds two numbers');
$graph['baselines']['master']['results'][$id] = 'nope';
$graph['baselines']['master']['tree'] = 'nope';
$graph['edges']['tests/Unit/GreeterTest.php'] = 'nope';
return $graph;
});
$result = $project->pest('--tia', ...$arguments);
expect($result->exitCode)->toBe(0, $result->describe())
->and($result->output)->not->toContain('TypeError')
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed');
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
/*
* Invariant 1 — sequential and parallel must agree — under a process that is
* torn down in the middle of a test file. A worker that flushed what it got to
* before dying has not seen enough of that file to license pruning the
* siblings it never reached.
*/
test('a run torn down mid-file does not prune the tests it never reached', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Calculator;
test('adds two numbers', function (): void {
expect((new Calculator)->add(1, 2))->toBe(3);
});
test('subtracts two numbers', function (): void {
exit(0);
});
PHP);
$project->git()->commit('a test that kills its own process');
$project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($delta->removed())->toBe(0, $delta->summary())
->and($delta->shaMoved())->toBeFalse($delta->summary())
->and($delta->structureMoved())->toBeFalse($delta->summary())
->and(array_keys($project->graph()['baselines']['master']['results']))
->toContain(Project::testId('tests/Unit/CalculatorTest.php', 'subtracts two numbers'));
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a fatal error mid-file is a test error, not a truncation', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/CalculatorTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Calculator;
test('adds two numbers', function (): void {
expect((new Calculator)->add(1, 2))->toBe(3);
});
test('subtracts two numbers', function (): void {
undefined_function_here();
});
PHP);
$project->git()->commit('a test that fatals');
$result = $project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->not->toBe(0)
->and($result->tally())->toContain('1 failed')
->and($delta->removed())->toBe(0, $delta->summary())
->and($delta->structureMoved())->toBeFalse($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
/*
* Invariant 2 — every command lands in exactly one tier and stays inside it —
* for the combinations that were never exercised.
*/
test('a green complete run leaves the graph exactly as it found it', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with([
'bail' => [['--bail']],
'stop-on-failure' => [['--stop-on-failure']],
'compact' => [['--compact']],
'parallel bail' => [['--parallel', '--processes=2', '--bail']],
'parallel one process' => [['--parallel', '--processes=1']],
'parallel more processes than files' => [['--parallel', '--processes=8']],
])->skipOnWindows();
test('--tia --no-tia is a plain run that still refreshes what it executed', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--no-tia', ...$arguments);
$delta = $project->delta();
expect($result->replayed())->toBe(0, $result->describe())
->and($result->tally())->toContain(Project::TOTAL_TESTS.' passed')
->and($delta->writtenCount())->toBe(Project::TOTAL_TESTS, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('--fresh on a partial run neither purges nor prunes', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$result = $project->pest('--tia', '--fresh', '--filter=adds two numbers', ...$arguments);
$delta = $project->delta();
expect($result->exitCode)->toBe(0, $result->describe())
->and($project->graphExists())->toBeTrue()
->and($delta->writtenCount())->toBe(1, $delta->summary())
->and($delta->isResultsOnly())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a second green run on a feature branch writes nothing at all', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->git()->switchTo('feature-x', new: true);
$project->pest('--tia', ...$arguments);
$project->snapshot();
$result = $project->pest('--tia', ...$arguments);
$delta = $project->delta();
expect($result->replayed())->toBe(Project::TOTAL_TESTS, $result->describe())
->and($delta->isHardSuppressed())->toBeTrue($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
+3 -5
View File
@@ -87,7 +87,7 @@ final readonly class GraphDelta
*/
public function branchKeys(): array
{
return array_keys($this->baselines($this->after));
return array_map(strval(...), array_keys($this->baselines($this->after)));
}
/**
@@ -95,7 +95,7 @@ final readonly class GraphDelta
*/
public function branchKeysBefore(): array
{
return array_keys($this->baselines($this->before));
return array_map(strval(...), array_keys($this->baselines($this->before)));
}
public function branchKeysMoved(): bool
@@ -254,9 +254,7 @@ final readonly class GraphDelta
$baselines = [];
foreach ($this->section($graph, 'baselines') as $branch => $baseline) {
if (is_string($branch)) {
$baselines[$branch] = $baseline;
}
$baselines[(string) $branch] = $baseline;
}
return $baselines;
+2 -1
View File
@@ -322,7 +322,8 @@ final class Project
{
$baselines = $this->graph()['baselines'] ?? [];
return is_array($baselines) ? array_keys($baselines) : [];
// A branch named `12345` comes back from json_decode as an integer key.
return is_array($baselines) ? array_map(strval(...), array_keys($baselines)) : [];
}
public function graphDir(): string