mirror of
https://github.com/pestphp/pest.git
synced 2026-04-23 07:27:27 +02:00
wip
This commit is contained in:
63
tests-tia/AffectedSetTest.php
Normal file
63
tests-tia/AffectedSetTest.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\TestsTia\Support\Sandbox;
|
||||
|
||||
/*
|
||||
* Mutating a source file should narrow replay to the tests that depend
|
||||
* on it. Untouched areas of the suite keep cache-hitting.
|
||||
*/
|
||||
|
||||
test('editing a source file marks only its dependents as affected', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
$sandbox->write('src/Math.php', <<<'PHP'
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Math
|
||||
{
|
||||
public static function add(int $a, int $b): int
|
||||
{
|
||||
return $a + $b;
|
||||
}
|
||||
|
||||
public static function sub(int $a, int $b): int
|
||||
{
|
||||
return $a - $b;
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
$process = $sandbox->pest(['--tia']);
|
||||
|
||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
||||
expect(tiaOutput($process))->toMatch('/2 affected,\s*1 replayed/');
|
||||
});
|
||||
});
|
||||
|
||||
test('adding a new test file runs the new test + replays the rest', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
$sandbox->write('tests/ExtraTest.php', <<<'PHP'
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
test('extra smoke', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
PHP);
|
||||
|
||||
$process = $sandbox->pest(['--tia']);
|
||||
|
||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
||||
expect(tiaOutput($process))->toMatch('/1 affected,\s*3 replayed/');
|
||||
});
|
||||
});
|
||||
52
tests-tia/FingerprintDriftTest.php
Normal file
52
tests-tia/FingerprintDriftTest.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\TestsTia\Support\Sandbox;
|
||||
|
||||
/*
|
||||
* Fingerprint splits into structural vs environmental. Hand-forge each
|
||||
* drift flavour on a valid graph and assert the right branch fires.
|
||||
*/
|
||||
|
||||
test('structural drift discards the graph entirely', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
$graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json';
|
||||
$graph = json_decode((string) file_get_contents($graphPath), true);
|
||||
$graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32);
|
||||
file_put_contents($graphPath, json_encode($graph));
|
||||
|
||||
$process = $sandbox->pest(['--tia']);
|
||||
|
||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
||||
expect(tiaOutput($process))->toContain('graph structure outdated');
|
||||
});
|
||||
});
|
||||
|
||||
test('environmental drift keeps edges, drops results', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
$graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json';
|
||||
$graph = json_decode((string) file_get_contents($graphPath), true);
|
||||
|
||||
$edgeCountBefore = count($graph['edges']);
|
||||
|
||||
$graph['fingerprint']['environmental']['php_minor'] = '7.4';
|
||||
$graph['fingerprint']['environmental']['extensions'] = str_repeat('0', 32);
|
||||
file_put_contents($graphPath, json_encode($graph));
|
||||
|
||||
$process = $sandbox->pest(['--tia']);
|
||||
|
||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
||||
expect(tiaOutput($process))->toContain('env differs from baseline');
|
||||
expect(tiaOutput($process))->toContain('results dropped, edges reused');
|
||||
|
||||
$graphAfter = $sandbox->graph();
|
||||
expect(count($graphAfter['edges']))->toBe($edgeCountBefore);
|
||||
expect($graphAfter['fingerprint']['environmental']['php_minor'])
|
||||
->not()->toBe('7.4');
|
||||
});
|
||||
});
|
||||
27
tests-tia/Fixtures/sample-project/composer.json
Normal file
27
tests-tia/Fixtures/sample-project/composer.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "pest/tia-sample-project",
|
||||
"type": "project",
|
||||
"description": "Throw-away fixture used by tests-tia to exercise TIA end-to-end.",
|
||||
"require": {
|
||||
"php": "^8.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
22
tests-tia/Fixtures/sample-project/phpunit.xml
Normal file
22
tests-tia/Fixtures/sample-project/phpunit.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="depends,defects"
|
||||
failOnRisky="false"
|
||||
failOnWarning="false"
|
||||
displayDetailsOnTestsThatTriggerWarnings="true"
|
||||
displayDetailsOnTestsThatTriggerNotices="true">
|
||||
<testsuites>
|
||||
<testsuite name="default">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
13
tests-tia/Fixtures/sample-project/src/Greeter.php
Normal file
13
tests-tia/Fixtures/sample-project/src/Greeter.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Greeter
|
||||
{
|
||||
public static function greet(string $name): string
|
||||
{
|
||||
return sprintf('Hello, %s!', $name);
|
||||
}
|
||||
}
|
||||
13
tests-tia/Fixtures/sample-project/src/Math.php
Normal file
13
tests-tia/Fixtures/sample-project/src/Math.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Math
|
||||
{
|
||||
public static function add(int $a, int $b): int
|
||||
{
|
||||
return $a + $b;
|
||||
}
|
||||
}
|
||||
9
tests-tia/Fixtures/sample-project/tests/GreeterTest.php
Normal file
9
tests-tia/Fixtures/sample-project/tests/GreeterTest.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Greeter;
|
||||
|
||||
test('greeter greets', function () {
|
||||
expect(Greeter::greet('Nuno'))->toBe('Hello, Nuno!');
|
||||
});
|
||||
13
tests-tia/Fixtures/sample-project/tests/MathTest.php
Normal file
13
tests-tia/Fixtures/sample-project/tests/MathTest.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Math;
|
||||
|
||||
test('math add', function () {
|
||||
expect(Math::add(2, 3))->toBe(5);
|
||||
});
|
||||
|
||||
test('math add negative', function () {
|
||||
expect(Math::add(-1, 1))->toBe(0);
|
||||
});
|
||||
7
tests-tia/Fixtures/sample-project/tests/Pest.php
Normal file
7
tests-tia/Fixtures/sample-project/tests/Pest.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Intentionally minimal — tests-tia exercises TIA against the simplest
|
||||
// possible Pest harness. Anything more and we end up debugging the
|
||||
// fixture instead of the feature under test.
|
||||
28
tests-tia/RebuildTest.php
Normal file
28
tests-tia/RebuildTest.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\TestsTia\Support\Sandbox;
|
||||
|
||||
/*
|
||||
* `--tia-rebuild` short-circuits whatever graph is on disk and records
|
||||
* from scratch. Used when the user knows the cache is wrong.
|
||||
*/
|
||||
|
||||
test('--tia-rebuild forces record mode even with a valid graph', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$sandbox->pest(['--tia']);
|
||||
expect($sandbox->hasGraph())->toBeTrue();
|
||||
|
||||
$graphBefore = $sandbox->graph();
|
||||
|
||||
$process = $sandbox->pest(['--tia', '--tia-rebuild']);
|
||||
|
||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
||||
expect(tiaOutput($process))->toContain('recording dependency graph');
|
||||
|
||||
$graphAfter = $sandbox->graph();
|
||||
expect(array_keys($graphAfter['edges']))
|
||||
->toEqualCanonicalizing(array_keys($graphBefore['edges']));
|
||||
});
|
||||
});
|
||||
42
tests-tia/RecordReplayTest.php
Normal file
42
tests-tia/RecordReplayTest.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\TestsTia\Support\Sandbox;
|
||||
|
||||
/*
|
||||
* The canonical cycle:
|
||||
* 1. Cold `--tia` run → record mode → graph written, tests pass.
|
||||
* 2. Subsequent `--tia` runs → replay mode, every test cache-hits.
|
||||
*/
|
||||
|
||||
test('cold run records the graph', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$process = $sandbox->pest(['--tia']);
|
||||
|
||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
||||
expect(tiaOutput($process))->toContain('recording dependency graph');
|
||||
expect($sandbox->hasGraph())->toBeTrue();
|
||||
|
||||
$graph = $sandbox->graph();
|
||||
expect($graph)->toHaveKey('edges');
|
||||
expect(array_keys($graph['edges']))->toContain('tests/MathTest.php');
|
||||
expect(array_keys($graph['edges']))->toContain('tests/GreeterTest.php');
|
||||
});
|
||||
});
|
||||
|
||||
test('warm run replays every test', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
// Cold pass: records edges AND snapshots results (series mode
|
||||
// runs `snapshotTestResults` in the same `addOutput` pass).
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
$process = $sandbox->pest(['--tia']);
|
||||
|
||||
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
|
||||
// Zero changes → only the `replayed` fragment appears in the
|
||||
// recap; the `affected` fragment is omitted when count is 0.
|
||||
expect(tiaOutput($process))->toMatch('/3 replayed/');
|
||||
expect(tiaOutput($process))->not()->toMatch('/\d+ affected/');
|
||||
});
|
||||
});
|
||||
46
tests-tia/SourceRevertTest.php
Normal file
46
tests-tia/SourceRevertTest.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\TestsTia\Support\Sandbox;
|
||||
|
||||
/*
|
||||
* Edit a source file, run TIA (tests re-run), revert to the original
|
||||
* bytes, run again — the revert is itself a change vs the previous
|
||||
* snapshot, so the affected tests re-execute rather than replaying the
|
||||
* stale bad-version cache.
|
||||
*/
|
||||
|
||||
test('reverting a modified file re-triggers its affected tests', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
$original = (string) file_get_contents($sandbox->path().'/src/Math.php');
|
||||
|
||||
$sandbox->write('src/Math.php', <<<'PHP'
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Math
|
||||
{
|
||||
public static function add(int $a, int $b): int
|
||||
{
|
||||
return 999; // broken
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
$broken = $sandbox->pest(['--tia']);
|
||||
expect($broken->isSuccessful())->toBeFalse();
|
||||
|
||||
$sandbox->write('src/Math.php', $original);
|
||||
|
||||
$recovered = $sandbox->pest(['--tia']);
|
||||
|
||||
expect($recovered->isSuccessful())->toBeTrue(tiaOutput($recovered));
|
||||
expect(tiaOutput($recovered))->toMatch('/2 affected,\s*1 replayed/');
|
||||
});
|
||||
});
|
||||
53
tests-tia/StatusReplayTest.php
Normal file
53
tests-tia/StatusReplayTest.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\TestsTia\Support\Sandbox;
|
||||
|
||||
/*
|
||||
* Cached statuses + assertion counts should survive replay.
|
||||
*/
|
||||
|
||||
test('assertion counts survive replay', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
$process = $sandbox->pest(['--tia']);
|
||||
$output = tiaOutput($process);
|
||||
|
||||
// MathTest has 2 assertions, GreeterTest has 1 → 3 total.
|
||||
// The "Tests: … (N assertions, … replayed)" banner should show 3.
|
||||
expect($output)->toMatch('/\(3 assertions/');
|
||||
});
|
||||
});
|
||||
|
||||
test('breaking a test replays as a failure on the next run', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
// Prime.
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
// Break the test. Its test file's edge map still points at
|
||||
// `src/Math.php`; editing the test file counts as a change
|
||||
// and the test re-executes.
|
||||
$sandbox->write('tests/MathTest.php', <<<'PHP'
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Math;
|
||||
|
||||
test('math add', function () {
|
||||
expect(Math::add(2, 3))->toBe(999); // wrong
|
||||
});
|
||||
|
||||
test('math add negative', function () {
|
||||
expect(Math::add(-1, 1))->toBe(0);
|
||||
});
|
||||
PHP);
|
||||
|
||||
$process = $sandbox->pest(['--tia']);
|
||||
|
||||
expect($process->isSuccessful())->toBeFalse();
|
||||
expect(tiaOutput($process))->toContain('math add');
|
||||
});
|
||||
});
|
||||
440
tests-tia/Support/Sandbox.php
Normal file
440
tests-tia/Support/Sandbox.php
Normal file
@ -0,0 +1,440 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\TestsTia\Support;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Throw-away sandbox for a TIA end-to-end scenario.
|
||||
*
|
||||
* On first call in a test run, a shared "template" sandbox is created
|
||||
* under the system temp dir and composer-installed against the host
|
||||
* Pest source. Subsequent `::create()` calls clone the template — cheap
|
||||
* (rcopy + git init) vs. running composer install per test.
|
||||
*
|
||||
* Each test owns its own clone; no cross-test state.
|
||||
*
|
||||
* Set `PEST_TIA_KEEP=1` to skip teardown so a failing scenario can be
|
||||
* reproduced manually — the path is emitted to STDERR.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Sandbox
|
||||
{
|
||||
private static ?string $templatePath = null;
|
||||
|
||||
private function __construct(private readonly string $path) {}
|
||||
|
||||
/**
|
||||
* Eagerly provision the shared template. Call once from the harness
|
||||
* bootstrap so parallel workers don't race on first `create()`.
|
||||
*/
|
||||
public static function warmTemplate(): void
|
||||
{
|
||||
self::ensureTemplate();
|
||||
}
|
||||
|
||||
public static function create(): self
|
||||
{
|
||||
$template = self::ensureTemplate();
|
||||
|
||||
$path = sys_get_temp_dir()
|
||||
.DIRECTORY_SEPARATOR
|
||||
.'pest-tia-sandbox-'
|
||||
.bin2hex(random_bytes(4));
|
||||
|
||||
self::rcopy($template, $path);
|
||||
self::bootstrapGit($path);
|
||||
|
||||
return new self($path);
|
||||
}
|
||||
|
||||
public function path(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function write(string $relative, string $content): void
|
||||
{
|
||||
$absolute = $this->path.DIRECTORY_SEPARATOR.$relative;
|
||||
$dir = dirname($absolute);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
throw new RuntimeException("Cannot create {$dir}");
|
||||
}
|
||||
|
||||
if (@file_put_contents($absolute, $content) === false) {
|
||||
throw new RuntimeException("Cannot write {$absolute}");
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(string $relative): void
|
||||
{
|
||||
$absolute = $this->path.DIRECTORY_SEPARATOR.$relative;
|
||||
|
||||
if (is_file($absolute)) {
|
||||
@unlink($absolute);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $flags
|
||||
*/
|
||||
public function pest(array $flags = []): Process
|
||||
{
|
||||
// Invoke Pest's bin script through PHP directly rather than the
|
||||
// `vendor/bin/pest` symlink — `rcopy()` loses the `+x` bit when
|
||||
// cloning the template. Going through `php` bypasses the exec
|
||||
// check. Use `PHP_BINARY` (not a bare `php`) so the sandbox
|
||||
// executes under the same interpreter that launched the outer
|
||||
// test suite — otherwise macOS multi-version setups (Herd, brew,
|
||||
// asdf, …) fall back to the first `php` on `$PATH`, which often
|
||||
// lacks the coverage driver TIA's record mode needs.
|
||||
$process = new Process(
|
||||
[PHP_BINARY, 'vendor/pestphp/pest/bin/pest', ...$flags],
|
||||
$this->path,
|
||||
[
|
||||
// Strip any CI signal so TIA doesn't suppress instructions.
|
||||
'GITHUB_ACTIONS' => '',
|
||||
'GITLAB_CI' => '',
|
||||
'CIRCLECI' => '',
|
||||
],
|
||||
);
|
||||
$process->setTimeout(120.0);
|
||||
$process->run();
|
||||
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function graph(): ?array
|
||||
{
|
||||
$path = $this->path.'/vendor/pestphp/pest/.temp/tia/graph.json';
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
public function hasGraph(): bool
|
||||
{
|
||||
return $this->graph() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $args
|
||||
*/
|
||||
public function git(array $args): Process
|
||||
{
|
||||
$process = new Process(['git', ...$args], $this->path);
|
||||
$process->setTimeout(30.0);
|
||||
$process->run();
|
||||
|
||||
return $process;
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
if (getenv('PEST_TIA_KEEP') === '1') {
|
||||
fwrite(STDERR, "[PEST_TIA_KEEP] sandbox: {$this->path}\n");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_dir($this->path)) {
|
||||
self::rrmdir($this->path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily provisions a once-per-process template with composer already
|
||||
* installed against the host Pest source. Every sandbox clone copies
|
||||
* from here, avoiding a ~30s composer install per test.
|
||||
*/
|
||||
private static function ensureTemplate(): string
|
||||
{
|
||||
if (self::$templatePath !== null && is_dir(self::$templatePath.'/vendor')) {
|
||||
return self::$templatePath;
|
||||
}
|
||||
|
||||
// Cache key includes a fingerprint of the host Pest source tree —
|
||||
// when we edit Pest internals, the key changes, old templates
|
||||
// become orphaned, the new template rebuilds. Without this, a
|
||||
// stale template with yesterday's Pest code silently masks today's
|
||||
// code under test.
|
||||
$template = sys_get_temp_dir()
|
||||
.DIRECTORY_SEPARATOR
|
||||
.'pest-tia-template-'
|
||||
.self::hostFingerprint();
|
||||
|
||||
// Serialise template creation across parallel paratest workers.
|
||||
// Without the lock, three workers hitting `ensureTemplate()`
|
||||
// simultaneously each see "no vendor yet → rebuild", stomp on
|
||||
// each other's composer install, and produce half-written
|
||||
// fixtures. `flock` on a sibling lockfile keeps it to one
|
||||
// builder; the others block, then observe the finished
|
||||
// template and skip straight to the fast path.
|
||||
$lockPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template.lock';
|
||||
$lock = fopen($lockPath, 'c');
|
||||
|
||||
if ($lock === false) {
|
||||
throw new RuntimeException('Cannot open template lock at '.$lockPath);
|
||||
}
|
||||
|
||||
flock($lock, LOCK_EX);
|
||||
|
||||
try {
|
||||
// Re-check after acquiring the lock — another worker may have
|
||||
// just finished the build while we were waiting.
|
||||
if (is_dir($template.'/vendor')) {
|
||||
self::$templatePath = $template;
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
// Garbage-collect every older template keyed by a different
|
||||
// fingerprint so /tmp doesn't accumulate a 200 MB graveyard
|
||||
// over a month of edits.
|
||||
foreach (glob(sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template-*') ?: [] as $orphan) {
|
||||
if ($orphan !== $template) {
|
||||
self::rrmdir($orphan);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_dir($template)) {
|
||||
self::rrmdir($template);
|
||||
}
|
||||
|
||||
$fixture = __DIR__.'/../Fixtures/sample-project';
|
||||
|
||||
if (! is_dir($fixture)) {
|
||||
throw new RuntimeException('Missing fixture at '.$fixture);
|
||||
}
|
||||
|
||||
if (! @mkdir($template, 0755, true) && ! is_dir($template)) {
|
||||
throw new RuntimeException('Cannot create template at '.$template);
|
||||
}
|
||||
|
||||
self::rcopy($fixture, $template);
|
||||
self::wireHostPest($template);
|
||||
self::composerInstall($template);
|
||||
|
||||
self::$templatePath = $template;
|
||||
|
||||
return $template;
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
}
|
||||
|
||||
private static function wireHostPest(string $path): void
|
||||
{
|
||||
$hostRoot = realpath(__DIR__.'/../..');
|
||||
|
||||
if ($hostRoot === false) {
|
||||
throw new RuntimeException('Cannot resolve host Pest root');
|
||||
}
|
||||
|
||||
$composerJson = $path.'/composer.json';
|
||||
$decoded = json_decode((string) file_get_contents($composerJson), true);
|
||||
|
||||
$decoded['repositories'] = [
|
||||
['type' => 'path', 'url' => $hostRoot, 'options' => ['symlink' => false]],
|
||||
];
|
||||
$decoded['require']['pestphp/pest'] = '*@dev';
|
||||
|
||||
file_put_contents(
|
||||
$composerJson,
|
||||
json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n",
|
||||
);
|
||||
}
|
||||
|
||||
private static function composerInstall(string $path): void
|
||||
{
|
||||
// Invoke composer via the *same* PHP binary that's running this
|
||||
// process. On macOS multi-version setups (Herd, brew, asdf, etc.)
|
||||
// the `composer` shebang often points at the system PHP, which
|
||||
// may not match the version the test suite booted with — leading
|
||||
// to "your PHP version does not satisfy the requirement" errors
|
||||
// even when the interpreter in use would satisfy it. Going
|
||||
// through `PHP_BINARY` + the located composer binary/phar
|
||||
// sidesteps that entirely.
|
||||
$composer = self::locateComposer();
|
||||
$args = $composer === null
|
||||
? ['composer', 'install']
|
||||
: [PHP_BINARY, $composer, 'install'];
|
||||
|
||||
$process = new Process(
|
||||
[...$args, '--no-interaction', '--prefer-dist', '--no-progress', '--quiet'],
|
||||
$path,
|
||||
);
|
||||
$process->setTimeout(600.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException(
|
||||
"composer install failed in template:\n".$process->getOutput().$process->getErrorOutput(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the composer binary to a real path PHP can execute. Returns
|
||||
* `null` when composer isn't findable, in which case the caller falls
|
||||
* back to invoking plain `composer` via `$PATH` (and hopes for the
|
||||
* best — usually fine on CI Linux runners).
|
||||
*/
|
||||
private static function locateComposer(): ?string
|
||||
{
|
||||
$probe = new Process(['command', '-v', 'composer']);
|
||||
$probe->run();
|
||||
|
||||
$path = trim($probe->getOutput());
|
||||
|
||||
if ($path === '' || ! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `composer` may be a shell-script wrapper (Herd does this) —
|
||||
// resolve the actual phar it invokes. Heuristic: parse out the
|
||||
// last `.phar` argument from the wrapper, fall back to the file
|
||||
// itself if no wrapper is detected.
|
||||
$content = @file_get_contents($path);
|
||||
|
||||
if ($content !== false && preg_match('/\S+\.phar/', $content, $m) === 1) {
|
||||
$phar = $m[0];
|
||||
|
||||
if (is_file($phar)) {
|
||||
return $phar;
|
||||
}
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private static function bootstrapGit(string $path): void
|
||||
{
|
||||
// Each clone needs its own repo — TIA's SHA / branch / diff logic
|
||||
// all rely on `.git/`. The template has no git dir so clones start
|
||||
// from a clean slate.
|
||||
$run = function (array $args) use ($path): void {
|
||||
$process = new Process(['git', ...$args], $path);
|
||||
$process->setTimeout(30.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException('git '.implode(' ', $args).' failed: '.$process->getErrorOutput());
|
||||
}
|
||||
};
|
||||
|
||||
// `.git` may have been cloned from the template if we ever add one
|
||||
// there — nuke it just in case so every sandbox starts fresh.
|
||||
if (is_dir($path.'/.git')) {
|
||||
self::rrmdir($path.'/.git');
|
||||
}
|
||||
|
||||
// Keep `vendor/` and composer lock out of the sandbox's git repo
|
||||
// entirely. With ~thousands of files `git add .` takes tens of
|
||||
// seconds; TIA also ignores vendor paths via `shouldIgnore()` so
|
||||
// tracking them buys nothing except slowness.
|
||||
file_put_contents($path.'/.gitignore', "vendor/\ncomposer.lock\n");
|
||||
|
||||
$run(['init', '-q', '-b', 'main']);
|
||||
$run(['config', 'user.email', 'sandbox@pest.test']);
|
||||
$run(['config', 'user.name', 'Pest Sandbox']);
|
||||
$run(['config', 'commit.gpgsign', 'false']);
|
||||
$run(['add', '.']);
|
||||
$run(['commit', '-q', '-m', 'initial']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short hash derived from the host Pest source that the template is
|
||||
* built against. Hashing the newest mtime across `src/`, `overrides/`,
|
||||
* and `composer.json` is cheap (one stat each) and catches every edit
|
||||
* that could alter TIA behaviour.
|
||||
*/
|
||||
private static function hostFingerprint(): string
|
||||
{
|
||||
$hostRoot = realpath(__DIR__.'/../..');
|
||||
|
||||
if ($hostRoot === false) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$newest = 0;
|
||||
|
||||
foreach ([$hostRoot.'/src', $hostRoot.'/overrides'] as $dir) {
|
||||
if (! is_dir($dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
);
|
||||
|
||||
foreach ($iter as $file) {
|
||||
if ($file->isFile()) {
|
||||
$newest = max($newest, $file->getMTime());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_file($hostRoot.'/composer.json')) {
|
||||
$newest = max($newest, (int) filemtime($hostRoot.'/composer.json'));
|
||||
}
|
||||
|
||||
return substr(sha1($hostRoot.'|'.PHP_VERSION.'|'.$newest), 0, 12);
|
||||
}
|
||||
|
||||
private static function rcopy(string $src, string $dest): void
|
||||
{
|
||||
if (! is_dir($dest) && ! @mkdir($dest, 0755, true) && ! is_dir($dest)) {
|
||||
throw new RuntimeException("Cannot create {$dest}");
|
||||
}
|
||||
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST,
|
||||
);
|
||||
|
||||
foreach ($iter as $item) {
|
||||
$target = $dest.DIRECTORY_SEPARATOR.$iter->getSubPathname();
|
||||
|
||||
if ($item->isDir()) {
|
||||
@mkdir($target, 0755, true);
|
||||
} else {
|
||||
copy($item->getPathname(), $target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function rrmdir(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `rm -rf` shells out but handles symlinks, read-only files, and
|
||||
// the composer-vendor quirks (lock files, .bin symlinks) that
|
||||
// PHP's own recursive delete stumbles on. Non-fatal on failure.
|
||||
$process = new Process(['rm', '-rf', $dir]);
|
||||
$process->setTimeout(60.0);
|
||||
$process->run();
|
||||
}
|
||||
}
|
||||
65
tests-tia/bootstrap.php
Normal file
65
tests-tia/bootstrap.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* tests-tia bootstrap.
|
||||
*
|
||||
* Pest's automatic `Pest.php` loader scans the configured `testDirectory()`
|
||||
* which defaults to `tests/` and is hard to override from a nested suite.
|
||||
* So instead of relying on `tests-tia/Pest.php` being found, wire the
|
||||
* helpers in via PHPUnit's explicit `bootstrap=` attribute — simpler,
|
||||
* no config-search surprises.
|
||||
*/
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
require __DIR__.'/Support/Sandbox.php';
|
||||
|
||||
use Pest\TestsTia\Support\Sandbox;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
// tests-tia exercises the record path end-to-end, which means the
|
||||
// sandbox PHP must expose a coverage driver (pcov or xdebug with
|
||||
// coverage mode). Without one, `--tia` records zero edges and every
|
||||
// scenario assertion fails with a useless "no coverage driver" banner.
|
||||
// Bail out loudly at bootstrap so the failure mode is obvious.
|
||||
if (! extension_loaded('pcov') && ! extension_loaded('xdebug')) {
|
||||
fwrite(STDERR, "\n");
|
||||
fwrite(STDERR, " \e[30;43m SKIP \e[0m tests-tia requires a coverage driver (pcov or xdebug).\n");
|
||||
fwrite(STDERR, " Install one, then retry: composer test:tia\n\n");
|
||||
|
||||
// Exit 0 so CI doesn't fail when the driver is genuinely absent —
|
||||
// the CI workflow adds pcov explicitly so this branch only fires on
|
||||
// dev machines that haven't set one up.
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Pre-warm the shared composer template once, up-front. Without this,
|
||||
// parallel workers race on first use — whoever hits `ensureTemplate()`
|
||||
// second gets a half-written template. A file-based lock + single
|
||||
// bootstrap pre-warm sidesteps the problem entirely.
|
||||
Sandbox::warmTemplate();
|
||||
|
||||
/**
|
||||
* Runs `$body` inside a fresh sandbox, guaranteeing teardown even when the
|
||||
* body throws. Keeps scenario tests tidy — one line per setup + destroy.
|
||||
*/
|
||||
function tiaScenario(Closure $body): void
|
||||
{
|
||||
$sandbox = Sandbox::create();
|
||||
|
||||
try {
|
||||
$body($sandbox);
|
||||
} finally {
|
||||
$sandbox->destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escapes so assertions are terminal-agnostic.
|
||||
*/
|
||||
function tiaOutput(Process $process): string
|
||||
{
|
||||
$output = $process->getOutput().$process->getErrorOutput();
|
||||
|
||||
return preg_replace('/\e\[[0-9;]*m/', '', $output) ?? $output;
|
||||
}
|
||||
17
tests-tia/phpunit.xml
Normal file
17
tests-tia/phpunit.xml
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="../vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="bootstrap.php"
|
||||
colors="true"
|
||||
cacheDirectory="../.phpunit.cache/tests-tia"
|
||||
executionOrder="default"
|
||||
failOnRisky="false"
|
||||
failOnWarning="false">
|
||||
<testsuites>
|
||||
<testsuite name="tia">
|
||||
<directory>.</directory>
|
||||
<exclude>Fixtures</exclude>
|
||||
<exclude>Support</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
Reference in New Issue
Block a user