mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
4 Commits
856a370032
...
d9c18f9c02
| Author | SHA1 | Date | |
|---|---|---|---|
| d9c18f9c02 | |||
| 660b57b365 | |||
| 68527c996f | |||
| c6a42a2b28 |
18
.github/workflows/tests.yml
vendored
18
.github/workflows/tests.yml
vendored
@ -76,3 +76,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Integration Tests
|
- name: Integration Tests
|
||||||
run: composer test:integration
|
run: composer test:integration
|
||||||
|
|
||||||
|
# tests-tia records coverage inside its sandbox, which requires
|
||||||
|
# pcov (or xdebug) in the process PHP. The main setup-php step is
|
||||||
|
# `coverage: none` for speed — re-enable pcov here just for the
|
||||||
|
# TIA step. Cheap: pcov startup is near-zero.
|
||||||
|
- name: Enable pcov for TIA
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php }}
|
||||||
|
tools: composer:v2
|
||||||
|
coverage: pcov
|
||||||
|
extensions: sockets
|
||||||
|
|
||||||
|
- name: TIA End-to-End Tests
|
||||||
|
# Black-box tests drive Pest `--tia` against a throw-away sandbox.
|
||||||
|
# First scenario takes ~60s (composer-installs the host Pest into a
|
||||||
|
# cached template); subsequent clones are cheap.
|
||||||
|
run: composer test:tia
|
||||||
|
|||||||
9
bin/pest
9
bin/pest
@ -142,6 +142,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
|
|
||||||
// Get $rootPath based on $autoloadPath
|
// Get $rootPath based on $autoloadPath
|
||||||
$rootPath = dirname($autoloadPath, 2);
|
$rootPath = dirname($autoloadPath, 2);
|
||||||
|
|
||||||
|
// Re-execs PHP without Xdebug on TIA replay runs so repeat `--tia`
|
||||||
|
// invocations aren't slowed by a coverage driver they don't use. Plain
|
||||||
|
// `pest` runs are left alone — users may rely on Xdebug for IDE
|
||||||
|
// breakpoints, step-through debugging, or custom tooling. See
|
||||||
|
// XdebugGuard for the full decision (coverage / tia-rebuild / Xdebug
|
||||||
|
// mode gates).
|
||||||
|
\Pest\Support\XdebugGuard::maybeDrop($rootPath);
|
||||||
|
|
||||||
$input = new ArgvInput;
|
$input = new ArgvInput;
|
||||||
|
|
||||||
$testSuite = TestSuite::getInstance(
|
$testSuite = TestSuite::getInstance(
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3.0",
|
"php": "^8.3.0",
|
||||||
"brianium/paratest": "^7.20.0",
|
"brianium/paratest": "^7.20.0",
|
||||||
|
"composer/xdebug-handler": "^3.0.5",
|
||||||
"nunomaduro/collision": "^8.9.4",
|
"nunomaduro/collision": "^8.9.4",
|
||||||
"nunomaduro/termwind": "^2.4.0",
|
"nunomaduro/termwind": "^2.4.0",
|
||||||
"pestphp/pest-plugin": "^4.0.0",
|
"pestphp/pest-plugin": "^4.0.0",
|
||||||
@ -92,6 +93,7 @@
|
|||||||
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
||||||
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
||||||
"test:integration": "php bin/pest --group=integration -v",
|
"test:integration": "php bin/pest --group=integration -v",
|
||||||
|
"test:tia": "php bin/pest --configuration=tests-tia/phpunit.xml",
|
||||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||||
"test": [
|
"test": [
|
||||||
"@test:lint",
|
"@test:lint",
|
||||||
@ -99,7 +101,8 @@
|
|||||||
"@test:type:coverage",
|
"@test:type:coverage",
|
||||||
"@test:unit",
|
"@test:unit",
|
||||||
"@test:parallel",
|
"@test:parallel",
|
||||||
"@test:integration"
|
"@test:integration",
|
||||||
|
"@test:tia"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
|
|||||||
@ -238,6 +238,44 @@ trait Testable
|
|||||||
|
|
||||||
$this->__cachedPass = false;
|
$this->__cachedPass = false;
|
||||||
|
|
||||||
|
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||||
|
|
||||||
|
$description = $method->description;
|
||||||
|
if ($this->dataName()) {
|
||||||
|
$description = str_contains((string) $description, ':dataset')
|
||||||
|
? str_replace(':dataset', str_replace('dataset ', '', $this->dataName()), (string) $description)
|
||||||
|
: $description.' with '.$this->dataName();
|
||||||
|
}
|
||||||
|
|
||||||
|
$description = htmlspecialchars(html_entity_decode((string) $description), ENT_NOQUOTES);
|
||||||
|
|
||||||
|
if ($method->repetitions > 1) {
|
||||||
|
$matches = [];
|
||||||
|
preg_match('/\((.*?)\)/', $description, $matches);
|
||||||
|
|
||||||
|
if (count($matches) > 1) {
|
||||||
|
if (str_contains($description, 'with '.$matches[0].' /')) {
|
||||||
|
$description = str_replace('with '.$matches[0].' /', '', $description);
|
||||||
|
} else {
|
||||||
|
$description = str_replace('with '.$matches[0], '', $description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$description .= ' @ repetition '.($matches[1].' of '.$method->repetitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->__description = self::$__latestDescription = $description;
|
||||||
|
self::$__latestAssignees = $method->assignees;
|
||||||
|
self::$__latestNotes = $method->notes;
|
||||||
|
self::$__latestIssues = $method->issues;
|
||||||
|
self::$__latestPrs = $method->prs;
|
||||||
|
|
||||||
|
// TIA replay short-circuit. Runs AFTER dataset/description/
|
||||||
|
// assignee metadata is populated so output and filtering still
|
||||||
|
// see the correct test name + tags on a cache hit, but BEFORE
|
||||||
|
// `parent::setUp()` and `beforeEach` so we skip the user's
|
||||||
|
// fixture setup (which is the whole point of replay — avoid
|
||||||
|
// paying for work whose outcome we already know).
|
||||||
/** @var Tia $tia */
|
/** @var Tia $tia */
|
||||||
$tia = Container::getInstance()->get(Tia::class);
|
$tia = Container::getInstance()->get(Tia::class);
|
||||||
$cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name());
|
$cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name());
|
||||||
@ -275,38 +313,6 @@ trait Testable
|
|||||||
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
|
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
|
||||||
}
|
}
|
||||||
|
|
||||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
|
||||||
|
|
||||||
$description = $method->description;
|
|
||||||
if ($this->dataName()) {
|
|
||||||
$description = str_contains((string) $description, ':dataset')
|
|
||||||
? str_replace(':dataset', str_replace('dataset ', '', $this->dataName()), (string) $description)
|
|
||||||
: $description.' with '.$this->dataName();
|
|
||||||
}
|
|
||||||
|
|
||||||
$description = htmlspecialchars(html_entity_decode((string) $description), ENT_NOQUOTES);
|
|
||||||
|
|
||||||
if ($method->repetitions > 1) {
|
|
||||||
$matches = [];
|
|
||||||
preg_match('/\((.*?)\)/', $description, $matches);
|
|
||||||
|
|
||||||
if (count($matches) > 1) {
|
|
||||||
if (str_contains($description, 'with '.$matches[0].' /')) {
|
|
||||||
$description = str_replace('with '.$matches[0].' /', '', $description);
|
|
||||||
} else {
|
|
||||||
$description = str_replace('with '.$matches[0], '', $description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$description .= ' @ repetition '.($matches[1].' of '.$method->repetitions);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->__description = self::$__latestDescription = $description;
|
|
||||||
self::$__latestAssignees = $method->assignees;
|
|
||||||
self::$__latestNotes = $method->notes;
|
|
||||||
self::$__latestIssues = $method->issues;
|
|
||||||
self::$__latestPrs = $method->prs;
|
|
||||||
|
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||||
|
|||||||
@ -30,7 +30,7 @@ use Throwable;
|
|||||||
* -----
|
* -----
|
||||||
* - **Record** — no graph (or fingerprint / recording commit drifted). The
|
* - **Record** — no graph (or fingerprint / recording commit drifted). The
|
||||||
* full suite runs with PCOV / Xdebug capture per test; the resulting
|
* full suite runs with PCOV / Xdebug capture per test; the resulting
|
||||||
* `test → [source_file, …]` edges land in `.temp/tia/graph.json`.
|
* `test → [source_file, …]` edges land in `.pest/tia/graph.json`.
|
||||||
* - **Replay** — graph valid. We diff the working tree against the recording
|
* - **Replay** — graph valid. We diff the working tree against the recording
|
||||||
* commit, intersect changed files with graph edges, and run only the
|
* commit, intersect changed files with graph edges, and run only the
|
||||||
* affected tests. Newly-added tests unknown to the graph are always
|
* affected tests. Newly-added tests unknown to the graph are always
|
||||||
@ -53,7 +53,7 @@ use Throwable;
|
|||||||
* - **Worker, record**: boots through `bin/worker.php`, which re-runs
|
* - **Worker, record**: boots through `bin/worker.php`, which re-runs
|
||||||
* `CallsHandleArguments`. We detect the worker context + recording flag,
|
* `CallsHandleArguments`. We detect the worker context + recording flag,
|
||||||
* activate the `Recorder`, and flush the partial graph on `terminate()`
|
* activate the `Recorder`, and flush the partial graph on `terminate()`
|
||||||
* into `.temp/tia/worker-edges-<TEST_TOKEN>.json`.
|
* into `.pest/tia/worker-edges-<TEST_TOKEN>.json`.
|
||||||
* - **Worker, replay**: nothing to do; args already narrowed.
|
* - **Worker, replay**: nothing to do; args already narrowed.
|
||||||
*
|
*
|
||||||
* Guardrails
|
* Guardrails
|
||||||
@ -72,13 +72,27 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private const string OPTION = '--tia';
|
private const string OPTION = '--tia';
|
||||||
|
|
||||||
private const string REBUILD_OPTION = '--tia-rebuild';
|
/**
|
||||||
|
* Discards any existing graph and re-records from scratch. Meant to
|
||||||
|
* be combined with `--tia`; the flag is shared with the rest of Pest
|
||||||
|
* (no `tia-` prefix) so a single `--tia --fresh` reads naturally as
|
||||||
|
* "TIA, fresh start".
|
||||||
|
*/
|
||||||
|
private const string FRESH_OPTION = '--fresh';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bypasses `BaselineSync`'s post-failure cooldown. After a failed
|
||||||
|
* baseline fetch, subsequent `--tia` runs skip the fetch for 24h; this
|
||||||
|
* flag forces an immediate retry (e.g. right after publishing a
|
||||||
|
* baseline from CI for the first time).
|
||||||
|
*/
|
||||||
|
private const string REFETCH_OPTION = '--tia-refetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State keys under which TIA persists its blobs. Kept here as constants
|
* State keys under which TIA persists its blobs. Kept here as constants
|
||||||
* (rather than scattered strings) so the storage layout is visible in
|
* (rather than scattered strings) so the storage layout is visible in
|
||||||
* one place, and so `CoverageMerger` can reference the same keys. All
|
* one place, and so `CoverageMerger` can reference the same keys. All
|
||||||
* files live under `.temp/tia/` — the `tia-` filename prefix is gone
|
* files live under `.pest/tia/` — the `tia-` filename prefix is gone
|
||||||
* because the directory already namespaces them.
|
* because the directory already namespaces them.
|
||||||
*/
|
*/
|
||||||
public const string KEY_GRAPH = 'graph.json';
|
public const string KEY_GRAPH = 'graph.json';
|
||||||
@ -103,6 +117,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
public const string KEY_COVERAGE_MARKER = 'coverage.marker';
|
public const string KEY_COVERAGE_MARKER = 'coverage.marker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds
|
||||||
|
* `{"until": <unix>}` — subsequent runs within the window skip the
|
||||||
|
* fetch attempt (and its `gh run list` network hop) until the
|
||||||
|
* cooldown expires or the user passes `--tia-refetch`.
|
||||||
|
*/
|
||||||
|
public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global flag toggled by the parent process so workers know to record.
|
* Global flag toggled by the parent process so workers know to record.
|
||||||
*/
|
*/
|
||||||
@ -110,7 +132,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Global flag that tells workers to install the TIA filter (replay mode).
|
* Global flag that tells workers to install the TIA filter (replay mode).
|
||||||
* Workers read the affected set from `.temp/tia/affected.json`.
|
* Workers read the affected set from `.pest/tia/affected.json`.
|
||||||
*/
|
*/
|
||||||
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
|
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
|
||||||
|
|
||||||
@ -199,6 +221,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private bool $recordingActive = false;
|
private bool $recordingActive = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `--tia-refetch` is in the current argv — `BaselineSync`
|
||||||
|
* uses it to bypass the post-failure fetch cooldown.
|
||||||
|
*/
|
||||||
|
private bool $forceRefetch = false;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OutputInterface $output,
|
private readonly OutputInterface $output,
|
||||||
private readonly Recorder $recorder,
|
private readonly Recorder $recorder,
|
||||||
@ -309,14 +337,23 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
||||||
|
|
||||||
$enabled = $this->hasArgument(self::OPTION, $arguments);
|
$enabled = $this->hasArgument(self::OPTION, $arguments);
|
||||||
$forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments);
|
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
|
||||||
|
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
|
||||||
|
|
||||||
if (! $enabled && ! $forceRebuild && ! $recordingGlobal && ! $replayingGlobal) {
|
// `--fresh` only takes effect alongside `--tia` (or from a
|
||||||
|
// worker that's already in TIA mode). Without `--tia`, Pest
|
||||||
|
// users could be passing `--fresh` to an unrelated plugin —
|
||||||
|
// silently ignore it here and let whatever else consumes it
|
||||||
|
// handle it. The flag isn't popped in that branch.
|
||||||
|
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
|
||||||
|
|
||||||
|
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
$arguments = $this->popArgument(self::OPTION, $arguments);
|
$arguments = $this->popArgument(self::OPTION, $arguments);
|
||||||
$arguments = $this->popArgument(self::REBUILD_OPTION, $arguments);
|
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
|
||||||
|
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
|
||||||
|
|
||||||
// When `--coverage` is active, piggyback on PHPUnit's CodeCoverage
|
// When `--coverage` is active, piggyback on PHPUnit's CodeCoverage
|
||||||
// instead of starting our own PCOV / Xdebug session. Running two
|
// instead of starting our own PCOV / Xdebug session. Running two
|
||||||
@ -401,6 +438,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$graph->replaceEdges($perTest);
|
$graph->replaceEdges($perTest);
|
||||||
$graph->pruneMissingTests();
|
$graph->pruneMissingTests();
|
||||||
|
|
||||||
|
// Fold in the results collected during this same record run. The
|
||||||
|
// `AddsOutput` pass that runs `snapshotTestResults` fires *before*
|
||||||
|
// `terminate()` in the shutdown chain, so by the time the graph
|
||||||
|
// lands on disk, the snapshot pass has already returned empty.
|
||||||
|
// Writing results here means a first `--tia` invocation produces
|
||||||
|
// a graph with edges *and* results — the immediate next run hits
|
||||||
|
// cache for every unchanged test rather than needing a "warm-up"
|
||||||
|
// pass.
|
||||||
|
$this->seedResultsInto($graph);
|
||||||
|
|
||||||
if (! $this->saveGraph($graph)) {
|
if (! $this->saveGraph($graph)) {
|
||||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
|
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
|
||||||
$recorder->reset();
|
$recorder->reset();
|
||||||
@ -635,7 +682,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
// to pull a team-shared baseline so fresh checkouts (new devs, CI
|
// to pull a team-shared baseline so fresh checkouts (new devs, CI
|
||||||
// containers) don't pay the full record cost. If the pull succeeds
|
// containers) don't pay the full record cost. If the pull succeeds
|
||||||
// the graph is re-read and reconciled against the local env.
|
// the graph is re-read and reconciled against the local env.
|
||||||
if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot)) {
|
if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
|
||||||
$graph = $this->loadGraph($projectRoot);
|
$graph = $this->loadGraph($projectRoot);
|
||||||
if ($graph instanceof Graph) {
|
if ($graph instanceof Graph) {
|
||||||
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
||||||
@ -1107,6 +1154,18 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
private function registerRecap(): void
|
private function registerRecap(): void
|
||||||
{
|
{
|
||||||
DefaultPrinter::addRecap(function (): string {
|
DefaultPrinter::addRecap(function (): string {
|
||||||
|
// Parallel mode: worker replays live in other processes and
|
||||||
|
// flushed their counters to disk on terminate. Collision's
|
||||||
|
// `writeRecap` fires inside `ExecutionFinished`, which is
|
||||||
|
// strictly before `addOutput` — so we must merge right here
|
||||||
|
// or the fragment below would read 0 and the suffix would
|
||||||
|
// silently disappear on `--tia --parallel`. The merge is
|
||||||
|
// idempotent: partial keys are deleted on read, so the
|
||||||
|
// later `addOutput` call becomes a no-op.
|
||||||
|
if (Parallel::isEnabled() && ! Parallel::isWorker()) {
|
||||||
|
$this->mergeWorkerReplayPartials();
|
||||||
|
}
|
||||||
|
|
||||||
$fragments = [];
|
$fragments = [];
|
||||||
|
|
||||||
if ($this->executedCount > 0) {
|
if ($this->executedCount > 0) {
|
||||||
@ -1147,6 +1206,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$this->saveGraph($graph);
|
$this->saveGraph($graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory equivalent of `snapshotTestResults()` — transfers the
|
||||||
|
* collected results straight into the given graph instance without a
|
||||||
|
* load/save round-trip. Used on the record path where the graph
|
||||||
|
* hasn't hit disk yet and a separate `loadGraph()` would find nothing.
|
||||||
|
*/
|
||||||
|
private function seedResultsInto(Graph $graph): void
|
||||||
|
{
|
||||||
|
/** @var ResultCollector $collector */
|
||||||
|
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||||
|
|
||||||
|
foreach ($collector->all() as $testId => $result) {
|
||||||
|
$graph->setResult(
|
||||||
|
$this->branch,
|
||||||
|
$testId,
|
||||||
|
$result['status'],
|
||||||
|
$result['message'],
|
||||||
|
$result['time'],
|
||||||
|
$result['assertions'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$collector->reset();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges per-test status + message from the `ResultCollector` into the
|
* Merges per-test status + message from the `ResultCollector` into the
|
||||||
* TIA graph. Runs after every `--tia` invocation so the graph always has
|
* TIA graph. Runs after every `--tia` invocation so the graph always has
|
||||||
|
|||||||
@ -17,7 +17,7 @@ use Symfony\Component\Process\Process;
|
|||||||
*
|
*
|
||||||
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
|
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
|
||||||
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
|
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
|
||||||
* suite under `--tia` and uploads the `.temp/tia/` directory as a named
|
* suite under `--tia` and uploads the `.pest/tia/` directory as a named
|
||||||
* artifact (`pest-tia-baseline`) containing `graph.json` +
|
* artifact (`pest-tia-baseline`) containing `graph.json` +
|
||||||
* `coverage.bin`. On dev
|
* `coverage.bin`. On dev
|
||||||
* machines, this class finds the latest successful run of that workflow
|
* machines, this class finds the latest successful run of that workflow
|
||||||
@ -62,6 +62,15 @@ final readonly class BaselineSync
|
|||||||
|
|
||||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cooldown (in seconds) applied after a failed baseline fetch.
|
||||||
|
* Rationale: when the remote workflow hasn't published yet, every
|
||||||
|
* `pest --tia` invocation would otherwise re-hit `gh run list` and
|
||||||
|
* re-print the publish instructions — noisy + slow. Back off for a
|
||||||
|
* day, let the user override with `--tia-refetch`.
|
||||||
|
*/
|
||||||
|
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private State $state,
|
private State $state,
|
||||||
private OutputInterface $output,
|
private OutputInterface $output,
|
||||||
@ -72,8 +81,12 @@ final readonly class BaselineSync
|
|||||||
* contents into the TIA state store. Returns true when the graph blob
|
* contents into the TIA state store. Returns true when the graph blob
|
||||||
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
|
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
|
||||||
* never reads it.
|
* never reads it.
|
||||||
|
*
|
||||||
|
* `$force = true` (driven by `--tia-refetch`) ignores the post-failure
|
||||||
|
* cooldown so the user can retry on demand without waiting out the
|
||||||
|
* 24h window.
|
||||||
*/
|
*/
|
||||||
public function fetchIfAvailable(string $projectRoot): bool
|
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
|
||||||
{
|
{
|
||||||
$repo = $this->detectGitHubRepo($projectRoot);
|
$repo = $this->detectGitHubRepo($projectRoot);
|
||||||
|
|
||||||
@ -81,6 +94,16 @@ final readonly class BaselineSync
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
|
||||||
|
$this->output->writeln(sprintf(
|
||||||
|
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
|
||||||
|
.'Override with <fg=cyan>--tia-refetch</>.',
|
||||||
|
$this->formatDuration($remaining),
|
||||||
|
));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$this->output->writeln(sprintf(
|
$this->output->writeln(sprintf(
|
||||||
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
||||||
$repo,
|
$repo,
|
||||||
@ -89,6 +112,7 @@ final readonly class BaselineSync
|
|||||||
$payload = $this->download($repo);
|
$payload = $this->download($repo);
|
||||||
|
|
||||||
if ($payload === null) {
|
if ($payload === null) {
|
||||||
|
$this->startCooldown();
|
||||||
$this->emitPublishInstructions($repo);
|
$this->emitPublishInstructions($repo);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@ -102,6 +126,11 @@ final readonly class BaselineSync
|
|||||||
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Successful fetch wipes any stale cooldown so the next failure
|
||||||
|
// (say, weeks later) starts a fresh 24h timer rather than inheriting
|
||||||
|
// one from the deep past.
|
||||||
|
$this->clearCooldown();
|
||||||
|
|
||||||
$this->output->writeln(sprintf(
|
$this->output->writeln(sprintf(
|
||||||
' <fg=green>TIA</> baseline ready (%s).',
|
' <fg=green>TIA</> baseline ready (%s).',
|
||||||
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
|
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
|
||||||
@ -110,6 +139,54 @@ final readonly class BaselineSync
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seconds left on the cooldown, or `null` when the cooldown is cleared
|
||||||
|
* / expired / unreadable.
|
||||||
|
*/
|
||||||
|
private function cooldownRemaining(): ?int
|
||||||
|
{
|
||||||
|
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
|
||||||
|
|
||||||
|
if ($raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining = $decoded['until'] - time();
|
||||||
|
|
||||||
|
return $remaining > 0 ? $remaining : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function startCooldown(): void
|
||||||
|
{
|
||||||
|
$this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([
|
||||||
|
'until' => time() + self::FETCH_COOLDOWN_SECONDS,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearCooldown(): void
|
||||||
|
{
|
||||||
|
$this->state->delete(Tia::KEY_FETCH_COOLDOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDuration(int $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds >= 3600) {
|
||||||
|
return (int) round($seconds / 3600).'h';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seconds >= 60) {
|
||||||
|
return (int) round($seconds / 60).'m';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $seconds.'s';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints actionable instructions for publishing a first baseline when
|
* Prints actionable instructions for publishing a first baseline when
|
||||||
* the consumer-side fetch finds nothing.
|
* the consumer-side fetch finds nothing.
|
||||||
@ -208,10 +285,15 @@ jobs:
|
|||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: php artisan key:generate
|
- run: php artisan key:generate
|
||||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
- run: ./vendor/bin/pest --parallel --tia --coverage
|
||||||
|
- name: Stage baseline for upload
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p .pest-tia-baseline
|
||||||
|
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: pest-tia-baseline
|
name: pest-tia-baseline
|
||||||
path: vendor/pestphp/pest/.temp/tia/
|
path: .pest-tia-baseline/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
YAML;
|
YAML;
|
||||||
}
|
}
|
||||||
@ -234,10 +316,15 @@ jobs:
|
|||||||
with: { php-version: '8.4', coverage: xdebug }
|
with: { php-version: '8.4', coverage: xdebug }
|
||||||
- run: composer install --no-interaction --prefer-dist
|
- run: composer install --no-interaction --prefer-dist
|
||||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
- run: ./vendor/bin/pest --parallel --tia --coverage
|
||||||
|
- name: Stage baseline for upload
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p .pest-tia-baseline
|
||||||
|
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: pest-tia-baseline
|
name: pest-tia-baseline
|
||||||
path: vendor/pestphp/pest/.temp/tia/
|
path: .pest-tia-baseline/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
YAML;
|
YAML;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ namespace Pest\Plugins\Tia;
|
|||||||
use Pest\Contracts\Bootstrapper as BootstrapperContract;
|
use Pest\Contracts\Bootstrapper as BootstrapperContract;
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
|
use Pest\TestSuite;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin-level container registrations for TIA. Runs as part of Kernel's
|
* Plugin-level container registrations for TIA. Runs as part of Kernel's
|
||||||
@ -32,19 +33,17 @@ final readonly class Bootstrapper implements BootstrapperContract
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TIA's own subdirectory under Pest's `.temp/`. Keeping every TIA blob
|
* TIA's per-project state directory. Default layout is
|
||||||
* in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad
|
* `~/.pest/tia/<project-key>/` so the graph survives `composer
|
||||||
* alongside PHPUnit's unrelated files (coverage.php, test-results,
|
* install`, stays out of the project tree, and is naturally shared
|
||||||
* code-coverage/) and makes the CI artifact-upload path a single
|
* across worktrees of the same repo. See {@see Storage} for the key
|
||||||
* directory instead of a list of individual files.
|
* derivation and the home-dir-missing fallback.
|
||||||
*/
|
*/
|
||||||
private function tempDir(): string
|
private function tempDir(): string
|
||||||
{
|
{
|
||||||
return __DIR__
|
$testSuite = $this->container->get(TestSuite::class);
|
||||||
.DIRECTORY_SEPARATOR.'..'
|
assert($testSuite instanceof TestSuite);
|
||||||
.DIRECTORY_SEPARATOR.'..'
|
|
||||||
.DIRECTORY_SEPARATOR.'..'
|
return Storage::tempDir($testSuite->rootPath);
|
||||||
.DIRECTORY_SEPARATOR.'.temp'
|
|
||||||
.DIRECTORY_SEPARATOR.'tia';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,7 @@ final readonly class Fingerprint
|
|||||||
// almost never matches a dev's Herd/Homebrew install, and
|
// almost never matches a dev's Herd/Homebrew install, and
|
||||||
// the patch rarely changes anything test-visible.
|
// the patch rarely changes anything test-visible.
|
||||||
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
|
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
|
||||||
'extensions' => self::extensionsFingerprint(),
|
'extensions' => self::extensionsFingerprint($projectRoot),
|
||||||
'pest' => self::readPestVersion($projectRoot),
|
'pest' => self::readPestVersion($projectRoot),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@ -174,24 +174,83 @@ final readonly class Fingerprint
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic hash of the PHP extension set: `ext-name@version` pairs
|
* Deterministic hash of the extensions the project actually depends on —
|
||||||
* sorted alphabetically and joined.
|
* the `ext-*` entries in composer.json's `require` / `require-dev`. An
|
||||||
|
* incidental extension loaded on the developer's machine (or on CI) but
|
||||||
|
* not declared as a dependency can't affect correctness of the test
|
||||||
|
* suite, so we ignore it here to keep the drift signal quiet.
|
||||||
|
*
|
||||||
|
* Declared extensions that aren't currently loaded record as `missing`,
|
||||||
|
* which is itself a drift signal worth surfacing.
|
||||||
*/
|
*/
|
||||||
private static function extensionsFingerprint(): string
|
private static function extensionsFingerprint(string $projectRoot): string
|
||||||
{
|
{
|
||||||
$extensions = get_loaded_extensions();
|
$extensions = self::declaredExtensions($projectRoot);
|
||||||
|
|
||||||
|
if ($extensions === []) {
|
||||||
|
return hash('xxh128', '');
|
||||||
|
}
|
||||||
|
|
||||||
sort($extensions);
|
sort($extensions);
|
||||||
|
|
||||||
$parts = [];
|
$parts = [];
|
||||||
|
|
||||||
foreach ($extensions as $name) {
|
foreach ($extensions as $name) {
|
||||||
$version = phpversion($name);
|
$version = phpversion($name);
|
||||||
$parts[] = $name.'@'.($version === false ? '?' : $version);
|
$parts[] = $name.'@'.($version === false ? 'missing' : $version);
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash('xxh128', implode("\n", $parts));
|
return hash('xxh128', implode("\n", $parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension names (without the `ext-` prefix) that appear as keys under
|
||||||
|
* `require` or `require-dev` in the project's composer.json. Returns
|
||||||
|
* an empty list when composer.json is missing / unreadable / malformed,
|
||||||
|
* so the environmental fingerprint stays stable in those cases rather
|
||||||
|
* than flapping.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function declaredExtensions(string $projectRoot): array
|
||||||
|
{
|
||||||
|
$path = $projectRoot.'/composer.json';
|
||||||
|
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (! is_array($data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$extensions = [];
|
||||||
|
|
||||||
|
foreach (['require', 'require-dev'] as $section) {
|
||||||
|
$packages = $data[$section] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($packages)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($packages) as $package) {
|
||||||
|
if (is_string($package) && str_starts_with($package, 'ext-')) {
|
||||||
|
$extensions[] = substr($package, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($extensions));
|
||||||
|
}
|
||||||
|
|
||||||
private static function readPestVersion(string $projectRoot): string
|
private static function readPestVersion(string $projectRoot): string
|
||||||
{
|
{
|
||||||
$installed = $projectRoot.'/vendor/composer/installed.json';
|
$installed = $projectRoot.'/vendor/composer/installed.json';
|
||||||
|
|||||||
170
src/Plugins/Tia/Storage.php
Normal file
170
src/Plugins/Tia/Storage.php
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves TIA's on-disk state directory.
|
||||||
|
*
|
||||||
|
* Default location: `$HOME/.pest/tia/<project-key>/`. Keeping state in the
|
||||||
|
* user's home directory (rather than under `vendor/pestphp/pest/`) means:
|
||||||
|
*
|
||||||
|
* - `composer install` / path-repo reinstalls don't wipe the graph.
|
||||||
|
* - The state lives outside the project tree, so there is nothing for
|
||||||
|
* users to gitignore or accidentally commit.
|
||||||
|
* - Multiple worktrees of the same repo share one cache naturally.
|
||||||
|
*
|
||||||
|
* The project key is derived from the git origin URL when available — a
|
||||||
|
* CI workflow running on `github.com/org/repo` and a developer's clone
|
||||||
|
* of the same remote both compute the *same* key, which is what lets the
|
||||||
|
* CI-uploaded baseline line up with the dev-side reader. When the project
|
||||||
|
* is not in git, the key falls back to a hash of the absolute path so
|
||||||
|
* unrelated projects on the same machine stay isolated.
|
||||||
|
*
|
||||||
|
* When no home directory is resolvable (`HOME` / `USERPROFILE` both
|
||||||
|
* unset — the tests-tia sandboxes strip these deliberately, and some
|
||||||
|
* locked-down CI environments do the same), state falls back to
|
||||||
|
* `<projectRoot>/.pest/tia/`. That path is project-local but still
|
||||||
|
* survives composer installs, so the degradation is graceful.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class Storage
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Directory where TIA's State blobs live for `$projectRoot`.
|
||||||
|
*/
|
||||||
|
public static function tempDir(string $projectRoot): string
|
||||||
|
{
|
||||||
|
$home = self::homeDir();
|
||||||
|
|
||||||
|
if ($home === null) {
|
||||||
|
return $projectRoot
|
||||||
|
.DIRECTORY_SEPARATOR.'.pest'
|
||||||
|
.DIRECTORY_SEPARATOR.'tia';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $home
|
||||||
|
.DIRECTORY_SEPARATOR.'.pest'
|
||||||
|
.DIRECTORY_SEPARATOR.'tia'
|
||||||
|
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on
|
||||||
|
* Windows. Returns null if neither resolves to an existing
|
||||||
|
* directory, in which case callers fall back to project-local state.
|
||||||
|
*/
|
||||||
|
private static function homeDir(): ?string
|
||||||
|
{
|
||||||
|
foreach (['HOME', 'USERPROFILE'] as $key) {
|
||||||
|
$value = getenv($key);
|
||||||
|
|
||||||
|
if (is_string($value) && $value !== '' && is_dir($value)) {
|
||||||
|
return rtrim($value, '/\\');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folder name for `$projectRoot` under `~/.pest/tia/`.
|
||||||
|
*
|
||||||
|
* Strategy — each step rules out a class of collision:
|
||||||
|
*
|
||||||
|
* 1. If the project has a git origin URL, use a **normalised** form
|
||||||
|
* (`host/org/repo`, lowercased, no `.git` suffix) as the input.
|
||||||
|
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
|
||||||
|
* and `https://github.com/foo/bar` all collapse to
|
||||||
|
* `github.com/foo/bar` — three developers cloning the same repo
|
||||||
|
* by different transports share one cache, which is what we want.
|
||||||
|
* 2. Otherwise, use the canonicalised absolute path (`realpath`).
|
||||||
|
* Two unrelated `app/` checkouts under different parent folders
|
||||||
|
* have different realpaths → different hashes → isolated.
|
||||||
|
* 3. Hash the chosen input with sha256 and keep the first 16 hex
|
||||||
|
* chars — 64 bits of entropy makes accidental collision
|
||||||
|
* astronomically unlikely even across thousands of projects.
|
||||||
|
* 4. Prefix with a slug of the project basename so `ls ~/.pest/tia/`
|
||||||
|
* is readable; the slug is cosmetic only, all isolation comes
|
||||||
|
* from the hash.
|
||||||
|
*
|
||||||
|
* Result: `myapp-a1b2c3d4e5f67890`.
|
||||||
|
*/
|
||||||
|
private static function projectKey(string $projectRoot): string
|
||||||
|
{
|
||||||
|
$origin = self::originIdentity($projectRoot);
|
||||||
|
|
||||||
|
$realpath = @realpath($projectRoot);
|
||||||
|
$input = $origin ?? ($realpath === false ? $projectRoot : $realpath);
|
||||||
|
|
||||||
|
$hash = substr(hash('sha256', $input), 0, 16);
|
||||||
|
$slug = self::slug(basename($projectRoot));
|
||||||
|
|
||||||
|
return $slug === '' ? $hash : $slug.'-'.$hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical git origin identity for `$projectRoot`, or null when
|
||||||
|
* no origin URL can be parsed. The returned form is
|
||||||
|
* `host/org/repo` (lowercased, `.git` stripped) so SSH / HTTPS / git
|
||||||
|
* protocol clones of the same remote produce the same value.
|
||||||
|
*/
|
||||||
|
private static function originIdentity(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$url = self::rawOriginUrl($projectRoot);
|
||||||
|
|
||||||
|
if ($url === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// git@host:org/repo(.git)
|
||||||
|
if (preg_match('#^[\w.-]+@([\w.-]+):([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
|
||||||
|
return strtolower($m[1].'/'.$m[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheme://[user@]host[:port]/org/repo(.git) — https, ssh, git, file
|
||||||
|
if (preg_match('#^[a-z]+://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
|
||||||
|
return strtolower($m[1].'/'.$m[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrecognised form — hash the raw URL so different inputs still
|
||||||
|
// diverge, but lowercased so the only variance is intentional.
|
||||||
|
return strtolower($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rawOriginUrl(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||||
|
|
||||||
|
if (! is_file($config)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($config);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) {
|
||||||
|
return trim($match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filesystem-safe kebab of `$name`. Cosmetic only — used as a
|
||||||
|
* human-readable prefix on the hash so `~/.pest/tia/` lists
|
||||||
|
* recognisable folders.
|
||||||
|
*/
|
||||||
|
private static function slug(string $name): string
|
||||||
|
{
|
||||||
|
$slug = strtolower($name);
|
||||||
|
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
|
||||||
|
|
||||||
|
return trim($slug, '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
178
src/Support/XdebugGuard.php
Normal file
178
src/Support/XdebugGuard.php
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Support;
|
||||||
|
|
||||||
|
use Composer\XdebugHandler\XdebugHandler;
|
||||||
|
use Pest\Plugins\Tia;
|
||||||
|
use Pest\Plugins\Tia\Fingerprint;
|
||||||
|
use Pest\Plugins\Tia\Graph;
|
||||||
|
use Pest\Plugins\Tia\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-execs the PHP process without Xdebug on TIA replay runs, matching the
|
||||||
|
* behaviour of composer, phpstan, rector, psalm and pint.
|
||||||
|
*
|
||||||
|
* Xdebug imposes a 30–50% runtime tax on every PHP process that loads it —
|
||||||
|
* even when nothing is actively tracing, profiling or breaking. Plain `pest`
|
||||||
|
* users might rely on Xdebug being loaded (IDE breakpoints, step-through
|
||||||
|
* debugging, custom tooling), so we intentionally leave non-TIA runs alone.
|
||||||
|
*
|
||||||
|
* The guard engages only when ALL of these hold:
|
||||||
|
* 1. `--tia` is present in argv.
|
||||||
|
* 2. No `--fresh` flag (forced record always drives the coverage
|
||||||
|
* driver; dropping Xdebug would break the recording).
|
||||||
|
* 3. No `--coverage*` flag (coverage runs need the driver regardless).
|
||||||
|
* 4. A valid graph already exists on disk AND its structural fingerprint
|
||||||
|
* matches the current environment — i.e. TIA will replay rather than
|
||||||
|
* record. Record runs need the driver.
|
||||||
|
* 5. Xdebug's configured mode is either empty or exactly `['coverage']`.
|
||||||
|
* Any other mode (debug, develop, trace, profile, gcstats) signals the
|
||||||
|
* user wants Xdebug for reasons unrelated to coverage, so we leave it
|
||||||
|
* alone even on replay.
|
||||||
|
*
|
||||||
|
* `PEST_ALLOW_XDEBUG=1` remains an explicit manual override; it is honoured
|
||||||
|
* natively by `composer/xdebug-handler`.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class XdebugGuard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Call as early as possible after composer autoload, before any Pest
|
||||||
|
* class beyond the autoloader is touched. Safe when Xdebug is not
|
||||||
|
* loaded (returns immediately) and when `composer/xdebug-handler` is
|
||||||
|
* unavailable (defensive `class_exists` check).
|
||||||
|
*/
|
||||||
|
public static function maybeDrop(string $projectRoot): void
|
||||||
|
{
|
||||||
|
if (! class_exists(XdebugHandler::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! extension_loaded('xdebug')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! self::xdebugIsCoverageOnly()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$argv = is_array($_SERVER['argv'] ?? null) ? $_SERVER['argv'] : [];
|
||||||
|
|
||||||
|
if (! self::runLooksDroppable($argv, $projectRoot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(new XdebugHandler('pest'))->check();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when Xdebug 3+ is running in coverage-only mode (or empty). False
|
||||||
|
* for older Xdebug without `xdebug_info` — be conservative and leave it
|
||||||
|
* loaded; we can't prove the mode is safe to drop.
|
||||||
|
*/
|
||||||
|
private static function xdebugIsCoverageOnly(): bool
|
||||||
|
{
|
||||||
|
if (! function_exists('xdebug_info')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$modes = @xdebug_info('mode');
|
||||||
|
|
||||||
|
if (! is_array($modes)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$modes = array_values(array_filter($modes, is_string(...)));
|
||||||
|
|
||||||
|
if ($modes === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $modes === ['coverage'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the argv-based rules: `--tia` must be present, no coverage
|
||||||
|
* flag, no forced rebuild, and TIA must be about to replay rather than
|
||||||
|
* record. Plain `pest` (and anything else without `--tia`) keeps Xdebug
|
||||||
|
* loaded so non-TIA users aren't surprised by behaviour changes.
|
||||||
|
*
|
||||||
|
* @param array<int, mixed> $argv
|
||||||
|
*/
|
||||||
|
private static function runLooksDroppable(array $argv, string $projectRoot): bool
|
||||||
|
{
|
||||||
|
$hasTia = false;
|
||||||
|
|
||||||
|
foreach ($argv as $value) {
|
||||||
|
if (! is_string($value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === '--coverage'
|
||||||
|
|| str_starts_with($value, '--coverage=')
|
||||||
|
|| str_starts_with($value, '--coverage-')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === '--fresh') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === '--tia') {
|
||||||
|
$hasTia = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hasTia) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::tiaWillReplay($projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a valid TIA graph already lives on disk AND its structural
|
||||||
|
* fingerprint matches the current environment. Any other outcome
|
||||||
|
* (missing graph, unreadable JSON, structural drift) means TIA will
|
||||||
|
* record and the driver must stay loaded.
|
||||||
|
*/
|
||||||
|
private static function tiaWillReplay(string $projectRoot): bool
|
||||||
|
{
|
||||||
|
$path = self::graphPath($projectRoot);
|
||||||
|
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = @file_get_contents($path);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$graph = Graph::decode($json, $projectRoot);
|
||||||
|
|
||||||
|
if (! $graph instanceof Graph) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fingerprint::structuralMatches(
|
||||||
|
$graph->fingerprint(),
|
||||||
|
Fingerprint::compute($projectRoot),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-disk location of the TIA graph — delegates to {@see Storage} so
|
||||||
|
* the writer (TIA's bootstrapper) and this reader stay in sync
|
||||||
|
* without a runtime container lookup (the container isn't booted yet
|
||||||
|
* at this point).
|
||||||
|
*/
|
||||||
|
private static function graphPath(string $projectRoot): string
|
||||||
|
{
|
||||||
|
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
|
||||||
|
}
|
||||||
|
}
|
||||||
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().'/.pest/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().'/.pest/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 --fresh` short-circuits whatever graph is on disk and records
|
||||||
|
* from scratch. Used when the user knows the cache is wrong.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('--tia --fresh 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', '--fresh']);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
447
tests-tia/Support/Sandbox.php
Normal file
447
tests-tia/Support/Sandbox.php
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
<?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' => '',
|
||||||
|
// Force TIA's Storage to fall back to the sandbox-local
|
||||||
|
// `.pest/tia/` layout. Without this, every sandbox run
|
||||||
|
// would dump state into the developer's real home dir
|
||||||
|
// (`~/.pest/tia/`), polluting it and making tests
|
||||||
|
// non-hermetic.
|
||||||
|
'HOME' => '',
|
||||||
|
'USERPROFILE' => '',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$process->setTimeout(120.0);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
return $process;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function graph(): ?array
|
||||||
|
{
|
||||||
|
$path = $this->path.'/.pest/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