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
|
||||
* 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
|
||||
* commit, intersect changed files with graph edges, and run only the
|
||||
* 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
|
||||
* `CallsHandleArguments`. We detect the worker context + recording flag,
|
||||
* 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.
|
||||
*
|
||||
* Guardrails
|
||||
@ -86,7 +86,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
* State keys under which TIA persists its blobs. Kept here as constants
|
||||
* (rather than scattered strings) so the storage layout is visible in
|
||||
* 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.
|
||||
*/
|
||||
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).
|
||||
* 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';
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ use Symfony\Component\Process\Process;
|
||||
*
|
||||
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
|
||||
* (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` +
|
||||
* `coverage.bin`. On dev
|
||||
* machines, this class finds the latest successful run of that workflow
|
||||
@ -285,10 +285,15 @@ jobs:
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: php artisan key:generate
|
||||
- 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
|
||||
with:
|
||||
name: pest-tia-baseline
|
||||
path: vendor/pestphp/pest/.temp/tia/
|
||||
path: .pest-tia-baseline/
|
||||
retention-days: 30
|
||||
YAML;
|
||||
}
|
||||
@ -311,10 +316,15 @@ jobs:
|
||||
with: { php-version: '8.4', coverage: xdebug }
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- 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
|
||||
with:
|
||||
name: pest-tia-baseline
|
||||
path: vendor/pestphp/pest/.temp/tia/
|
||||
path: .pest-tia-baseline/
|
||||
retention-days: 30
|
||||
YAML;
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ namespace Pest\Plugins\Tia;
|
||||
use Pest\Contracts\Bootstrapper as BootstrapperContract;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad
|
||||
* alongside PHPUnit's unrelated files (coverage.php, test-results,
|
||||
* code-coverage/) and makes the CI artifact-upload path a single
|
||||
* directory instead of a list of individual files.
|
||||
* TIA's per-project state directory. Default layout is
|
||||
* `~/.pest/tia/<project-key>/` so the graph survives `composer
|
||||
* install`, stays out of the project tree, and is naturally shared
|
||||
* across worktrees of the same repo. See {@see Storage} for the key
|
||||
* derivation and the home-dir-missing fallback.
|
||||
*/
|
||||
private function tempDir(): string
|
||||
{
|
||||
return __DIR__
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'.temp'
|
||||
.DIRECTORY_SEPARATOR.'tia';
|
||||
$testSuite = $this->container->get(TestSuite::class);
|
||||
assert($testSuite instanceof TestSuite);
|
||||
|
||||
return Storage::tempDir($testSuite->rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
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\Fingerprint;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
use Pest\Plugins\Tia\Storage;
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$path = self::graphPath();
|
||||
$path = self::graphPath($projectRoot);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return false;
|
||||
@ -165,15 +166,13 @@ final class XdebugGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* On-disk location of the TIA graph — mirrors `Bootstrapper::tempDir()`
|
||||
* so both writer and reader stay in sync without a runtime container
|
||||
* lookup (the container isn't booted yet at this point).
|
||||
* 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
|
||||
private static function graphPath(string $projectRoot): string
|
||||
{
|
||||
return dirname(__DIR__, 2)
|
||||
.DIRECTORY_SEPARATOR.'.temp'
|
||||
.DIRECTORY_SEPARATOR.'tia'
|
||||
.DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
|
||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ test('structural drift discards the graph entirely', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
$graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json';
|
||||
$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));
|
||||
@ -29,7 +29,7 @@ test('environmental drift keeps edges, drops results', function () {
|
||||
tiaScenario(function (Sandbox $sandbox) {
|
||||
$sandbox->pest(['--tia']);
|
||||
|
||||
$graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json';
|
||||
$graphPath = $sandbox->path().'/.pest/tia/graph.json';
|
||||
$graph = json_decode((string) file_get_contents($graphPath), true);
|
||||
|
||||
$edgeCountBefore = count($graph['edges']);
|
||||
|
||||
@ -101,6 +101,13 @@ final class Sandbox
|
||||
'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);
|
||||
@ -114,7 +121,7 @@ final class Sandbox
|
||||
*/
|
||||
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)) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user