mirror of
https://github.com/pestphp/pest.git
synced 2026-04-23 07:27:27 +02:00
wip
This commit is contained in:
@ -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
|
||||||
@ -86,7 +86,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
* 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';
|
||||||
@ -126,7 +126,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';
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
@ -285,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;
|
||||||
}
|
}
|
||||||
@ -311,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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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, '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ use Composer\XdebugHandler\XdebugHandler;
|
|||||||
use Pest\Plugins\Tia;
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Plugins\Tia\Fingerprint;
|
use Pest\Plugins\Tia\Fingerprint;
|
||||||
use Pest\Plugins\Tia\Graph;
|
use Pest\Plugins\Tia\Graph;
|
||||||
|
use Pest\Plugins\Tia\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-execs the PHP process without Xdebug on TIA replay runs, matching the
|
* Re-execs the PHP process without Xdebug on TIA replay runs, matching the
|
||||||
@ -140,7 +141,7 @@ final class XdebugGuard
|
|||||||
*/
|
*/
|
||||||
private static function tiaWillReplay(string $projectRoot): bool
|
private static function tiaWillReplay(string $projectRoot): bool
|
||||||
{
|
{
|
||||||
$path = self::graphPath();
|
$path = self::graphPath($projectRoot);
|
||||||
|
|
||||||
if (! is_file($path)) {
|
if (! is_file($path)) {
|
||||||
return false;
|
return false;
|
||||||
@ -165,15 +166,13 @@ final class XdebugGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On-disk location of the TIA graph — mirrors `Bootstrapper::tempDir()`
|
* On-disk location of the TIA graph — delegates to {@see Storage} so
|
||||||
* so both writer and reader stay in sync without a runtime container
|
* the writer (TIA's bootstrapper) and this reader stay in sync
|
||||||
* lookup (the container isn't booted yet at this point).
|
* without a runtime container lookup (the container isn't booted yet
|
||||||
|
* at this point).
|
||||||
*/
|
*/
|
||||||
private static function graphPath(): string
|
private static function graphPath(string $projectRoot): string
|
||||||
{
|
{
|
||||||
return dirname(__DIR__, 2)
|
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
|
||||||
.DIRECTORY_SEPARATOR.'.temp'
|
|
||||||
.DIRECTORY_SEPARATOR.'tia'
|
|
||||||
.DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ test('structural drift discards the graph entirely', function () {
|
|||||||
tiaScenario(function (Sandbox $sandbox) {
|
tiaScenario(function (Sandbox $sandbox) {
|
||||||
$sandbox->pest(['--tia']);
|
$sandbox->pest(['--tia']);
|
||||||
|
|
||||||
$graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json';
|
$graphPath = $sandbox->path().'/.pest/tia/graph.json';
|
||||||
$graph = json_decode((string) file_get_contents($graphPath), true);
|
$graph = json_decode((string) file_get_contents($graphPath), true);
|
||||||
$graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32);
|
$graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32);
|
||||||
file_put_contents($graphPath, json_encode($graph));
|
file_put_contents($graphPath, json_encode($graph));
|
||||||
@ -29,7 +29,7 @@ test('environmental drift keeps edges, drops results', function () {
|
|||||||
tiaScenario(function (Sandbox $sandbox) {
|
tiaScenario(function (Sandbox $sandbox) {
|
||||||
$sandbox->pest(['--tia']);
|
$sandbox->pest(['--tia']);
|
||||||
|
|
||||||
$graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json';
|
$graphPath = $sandbox->path().'/.pest/tia/graph.json';
|
||||||
$graph = json_decode((string) file_get_contents($graphPath), true);
|
$graph = json_decode((string) file_get_contents($graphPath), true);
|
||||||
|
|
||||||
$edgeCountBefore = count($graph['edges']);
|
$edgeCountBefore = count($graph['edges']);
|
||||||
|
|||||||
@ -101,6 +101,13 @@ final class Sandbox
|
|||||||
'GITHUB_ACTIONS' => '',
|
'GITHUB_ACTIONS' => '',
|
||||||
'GITLAB_CI' => '',
|
'GITLAB_CI' => '',
|
||||||
'CIRCLECI' => '',
|
'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->setTimeout(120.0);
|
||||||
@ -114,7 +121,7 @@ final class Sandbox
|
|||||||
*/
|
*/
|
||||||
public function graph(): ?array
|
public function graph(): ?array
|
||||||
{
|
{
|
||||||
$path = $this->path.'/vendor/pestphp/pest/.temp/tia/graph.json';
|
$path = $this->path.'/.pest/tia/graph.json';
|
||||||
|
|
||||||
if (! is_file($path)) {
|
if (! is_file($path)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user