This commit is contained in:
nuno maduro
2026-04-20 14:28:18 -07:00
parent 59e781e77b
commit 2892341c28
3 changed files with 563 additions and 1 deletions

View File

@ -70,7 +70,12 @@ final readonly class Kernel
->add(Tia\CoverageCollector::class, new Tia\CoverageCollector)
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
->add(Tia\ResultCollector::class, new Tia\ResultCollector)
->add(Tia\Contracts\State::class, new Tia\FileState(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'.temp'));
->add(Tia\Contracts\State::class, new Tia\FileState(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'.temp'))
->add(Tia\BaselineSync::class, new Tia\BaselineSync(
$container->get(Tia\Contracts\State::class), // @phpstan-ignore argument.type
$output,
$input,
));
$kernel = new self(
new Application,

View File

@ -8,6 +8,7 @@ use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use PHPUnit\Framework\TestStatus\TestStatus;
use Pest\Plugins\Tia\BaselineSync;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Plugins\Tia\CoverageCollector;
@ -72,6 +73,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string REBUILD_OPTION = '--tia-rebuild';
private const string PUBLISH_OPTION = '--tia-publish';
/**
* State keys under which TIA persists its blobs. Kept here as constants
* (rather than scattered strings) so the storage layout is visible in
@ -195,6 +198,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private readonly CoverageCollector $coverageCollector,
private readonly WatchPatterns $watchPatterns,
private readonly State $state,
private readonly BaselineSync $baselineSync,
) {}
/**
@ -272,6 +276,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
// `--tia-publish` is its own entry point: it neither records nor
// replays, it just uploads whatever baseline is already on disk
// and exits. Handled before the usual `--tia` gating so users can
// publish without also triggering a suite run.
if (! $isWorker && $this->hasArgument(self::PUBLISH_OPTION, $arguments)) {
$projectRoot = TestSuite::getInstance()->rootPath;
exit($this->baselineSync->publish($projectRoot));
}
$enabled = $this->hasArgument(self::OPTION, $arguments);
$forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments);
@ -533,6 +547,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
}
// No local graph and not being forced to rebuild from scratch: try
// to pull a team-shared baseline so fresh checkouts (new devs, CI
// containers) don't pay the full record cost. If the pull succeeds
// the graph is re-read and re-validated against the local env.
if ($graph === null && ! $forceRebuild) {
if ($this->baselineSync->fetchIfAvailable($projectRoot)) {
$graph = $this->loadGraph($projectRoot);
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
$this->output->writeln(
' <fg=yellow>TIA</> pulled baseline fingerprint mismatch — discarding.',
);
$this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE);
$graph = null;
}
}
}
// Drop the marker so `Support\Coverage::report()` knows to merge the
// current (narrow) coverage with the cached full-run snapshot. Plain
// `--coverage` runs don't drop it, so their behaviour is untouched.

View File

@ -0,0 +1,524 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* Pulls a team-shared TIA baseline on the first `--tia` run so new
* contributors and fresh CI workspaces start in replay mode instead of
* paying the ~30s record cost.
*
* The baseline lives as a GitHub Release with a fixed tag containing two
* assets — the graph JSON and the coverage cache. The repo is inferred
* from `.git/config`'s `origin` remote, so no per-project configuration
* is required. Non-GitHub remotes silently opt out.
*
* Fetching is attempted in order:
* 1. `gh release download` — uses the user's existing GitHub auth,
* works for private repos.
* 2. Plain HTTPS — public-repo fallback when `gh` isn't installed.
*
* Fingerprint validation happens back in `Tia::handleParent` after the
* blobs are written: a mismatched environment (different PHP version,
* composer.lock, etc.) discards the pulled baseline and falls through to
* the regular record path.
*
* @internal
*/
final class BaselineSync
{
/**
* Conventional tag the CI recipe publishes under. Not configurable for
* MVP — if teams outgrow the convention, a `PEST_TIA_BASELINE_TAG` env
* var is the likely escape hatch.
*/
private const string RELEASE_TAG = 'pest-tia-baseline';
/**
* Asset filenames within the release — mirror the state keys so the
* CI publisher and the sync consumer stay in lock-step.
*/
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
public function __construct(
private readonly State $state,
private readonly OutputInterface $output,
private readonly InputInterface $input,
) {}
/**
* Attempts the full detect → prompt → download flow. Returns true when
* the graph blob was pulled and written to state. Coverage is best-
* effort: its absence doesn't fail the sync, since plain `--tia` (no
* `--coverage`) works fine without it.
*/
public function fetchIfAvailable(string $projectRoot): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
if ($repo === null) {
return false;
}
if (! $this->confirm($repo)) {
return false;
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
$repo,
));
$graphJson = $this->download($repo, self::GRAPH_ASSET);
if ($graphJson === null) {
$this->output->writeln(
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
);
return false;
}
if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) {
return false;
}
// Coverage cache is optional. The baseline is useful even without
// it (plain `--tia` never needs it) so don't fail the whole sync
// just because this asset is missing or slow.
$coverageBin = $this->download($repo, self::COVERAGE_ASSET);
if ($coverageBin !== null) {
$this->state->write(Tia::KEY_COVERAGE_CACHE, $coverageBin);
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline ready (%s).',
$this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')),
));
return true;
}
/**
* Publishes the *local* baseline to GitHub Releases under the
* conventional tag, creating the release on first run or uploading
* into the existing one otherwise.
*
* Uploading from a developer workstation is intentionally discouraged
* — CI is the authoritative publisher because its environment is
* reproducible, its working tree is clean, and its result cache
* isn't contaminated by local flakiness. The prompt here defaults to
* *No* to keep this an explicit, opt-in action.
*
* Returns a CLI-style exit code so the caller can `exit()` on it.
*/
public function publish(string $projectRoot): int
{
$graphBytes = $this->state->read(Tia::KEY_GRAPH);
if ($graphBytes === null) {
$this->output->writeln([
'',
' <fg=red>TIA</> no local baseline to publish.',
' Run <fg=cyan>./vendor/bin/pest --tia</> first to record one, then retry.',
'',
]);
return 1;
}
$repo = $this->detectGitHubRepo($projectRoot);
if ($repo === null) {
$this->output->writeln([
'',
' <fg=red>TIA</> cannot infer a GitHub repo from <fg=gray>.git/config</>.',
' Publishing is supported only for GitHub-hosted projects.',
'',
]);
return 1;
}
if (! $this->commandExists('gh')) {
$this->output->writeln([
'',
' <fg=red>TIA</> publishing requires the <fg=cyan>gh</> CLI.',
' Install: <fg=gray>https://cli.github.com</>',
'',
]);
return 1;
}
$this->output->writeln([
'',
' <fg=black;bg=yellow> WARNING </> Publishing local baselines is discouraged.',
'',
' Local runs can bake flaky results or dirty working-tree state into the',
' baseline, which your team then replays. CI-published baselines are safer.',
' See <fg=gray>https://pestphp.com/docs/tia/ci</> for the recommended workflow.',
'',
]);
if (! $this->confirmPublish($repo)) {
$this->output->writeln(' <fg=yellow>TIA</> publish cancelled.');
return 0;
}
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-publish-'.bin2hex(random_bytes(4));
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
$this->output->writeln(' <fg=red>TIA</> failed to create temp dir for upload.');
return 1;
}
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
if (@file_put_contents($graphPath, $graphBytes) === false) {
$this->cleanup($tmpDir);
return 1;
}
$filesToUpload = [$graphPath];
$coverageBytes = $this->state->read(Tia::KEY_COVERAGE_CACHE);
if ($coverageBytes !== null) {
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
if (@file_put_contents($coveragePath, $coverageBytes) !== false) {
$filesToUpload[] = $coveragePath;
}
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> publishing to <fg=white>%s</> (tag <fg=white>%s</>)…',
$repo,
self::RELEASE_TAG,
));
$exitCode = $this->ghReleaseUploadOrCreate($repo, $filesToUpload);
$this->cleanup($tmpDir);
if ($exitCode !== 0) {
$this->output->writeln(' <fg=red>TIA</> <fg=cyan>gh release</> failed.');
return $exitCode;
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline published (%s).',
$this->formatSize(strlen($graphBytes) + ($coverageBytes === null ? 0 : strlen($coverageBytes))),
));
return 0;
}
/**
* Uploads into the existing release if present, falls back to
* creating the release with the assets attached on first run.
*
* @param array<int, string> $files
*/
private function ghReleaseUploadOrCreate(string $repo, array $files): int
{
$uploadArgs = ['gh', 'release', 'upload', self::RELEASE_TAG, ...$files, '-R', $repo, '--clobber'];
$upload = new Process($uploadArgs);
$upload->setTimeout(300.0);
$upload->run(function (string $_, string $buffer): void {
$this->output->write($buffer);
});
if ($upload->isSuccessful()) {
return 0;
}
// Release likely doesn't exist yet — create it, attaching the files.
$createArgs = [
'gh', 'release', 'create', self::RELEASE_TAG,
...$files,
'-R', $repo,
'--title', 'Pest TIA baseline',
'--notes', 'Machine-generated baseline for Pest TIA. Do not edit manually.',
];
$create = new Process($createArgs);
$create->setTimeout(300.0);
$create->run(function (string $_, string $buffer): void {
$this->output->write($buffer);
});
return $create->isSuccessful() ? 0 : 1;
}
private function confirmPublish(string $repo): bool
{
if (! $this->isTerminal()) {
return false;
}
$this->output->writeln(sprintf(
' Publish to <fg=white>%s</> (tag <fg=white>%s</>)? <fg=gray>[y/N]</>',
$repo,
self::RELEASE_TAG,
));
$handle = @fopen('php://stdin', 'r');
if ($handle === false) {
return false;
}
$line = fgets($handle);
fclose($handle);
if ($line === false) {
return false;
}
// Unlike the fetch prompt, this one defaults to *No*. Empty input
// or anything other than an explicit "y"/"yes" cancels.
$line = strtolower(trim($line));
return $line === 'y' || $line === 'yes';
}
/**
* Parses `.git/config` for the `origin` remote and extracts
* `org/repo`. Supports the two URL flavours git emits out of the box.
* Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which
* silently opts the repo out of auto-sync.
*/
private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
if (! is_file($gitConfig)) {
return null;
}
$content = @file_get_contents($gitConfig);
if ($content === false) {
return null;
}
// Find the `[remote "origin"]` section and the first `url` line
// inside it. Tolerates INI whitespace quirks (tabs, CRLF).
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}
$url = $match[1];
// SSH: git@github.com:org/repo(.git)
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1];
}
// HTTPS: https://github.com/org/repo(.git) (optional trailing slash)
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return $m[1];
}
return null;
}
/**
* One-shot Y/n prompt. Defaults to Y. In non-interactive shells (CI,
* piped input) returns false so scripted runs never hang waiting for
* input.
*/
private function confirm(string $repo): bool
{
if (! $this->isTerminal()) {
return false;
}
$this->output->writeln('');
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> no local cache — fetch baseline from <fg=white>%s</>? <fg=gray>[Y/n]</>',
$repo,
));
$handle = @fopen('php://stdin', 'r');
if ($handle === false) {
return false;
}
$line = fgets($handle);
fclose($handle);
if ($line === false) {
return false;
}
$line = strtolower(trim($line));
return $line === '' || $line === 'y' || $line === 'yes';
}
/**
* Real-TTY check for STDIN. Symfony's `isInteractive()` defaults to true
* unless `--no-interaction` is explicitly passed, which would make
* scripted invocations (CI, pipes, subshells) hang at a prompt nobody
* sees. Combining both signals is the safe default.
*/
private function isTerminal(): bool
{
if (! $this->input->isInteractive()) {
return false;
}
if (! defined('STDIN')) {
return false;
}
if (function_exists('posix_isatty')) {
return @posix_isatty(STDIN) === true;
}
if (function_exists('stream_isatty')) {
return @stream_isatty(STDIN) === true;
}
return false;
}
/**
* Tries `gh` first (handles private repos + rate limiting via the
* user's GitHub auth), falls through to public HTTPS. Returns the
* raw asset bytes, or null on any failure.
*/
private function download(string $repo, string $asset): ?string
{
$viaGh = $this->downloadViaGh($repo, $asset);
if ($viaGh !== null) {
return $viaGh;
}
return $this->downloadViaHttps($repo, $asset);
}
private function downloadViaGh(string $repo, string $asset): ?string
{
if (! $this->commandExists('gh')) {
return null;
}
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
return null;
}
$process = new Process([
'gh', 'release', 'download', self::RELEASE_TAG,
'-R', $repo,
'-p', $asset,
'-D', $tmpDir,
'--clobber',
]);
$process->setTimeout(120.0);
$process->run();
$payload = null;
if ($process->isSuccessful()) {
$path = $tmpDir.DIRECTORY_SEPARATOR.$asset;
if (is_file($path)) {
$content = @file_get_contents($path);
$payload = $content === false ? null : $content;
}
}
$this->cleanup($tmpDir);
return $payload;
}
private function downloadViaHttps(string $repo, string $asset): ?string
{
$url = sprintf(
'https://github.com/%s/releases/download/%s/%s',
$repo,
self::RELEASE_TAG,
$asset,
);
$ctx = stream_context_create([
'http' => [
'timeout' => 120,
'follow_location' => 1,
'ignore_errors' => false,
],
]);
$content = @file_get_contents($url, false, $ctx);
return $content === false ? null : $content;
}
private function commandExists(string $cmd): bool
{
$probe = new Process(['command', '-v', $cmd]);
$probe->run();
if ($probe->isSuccessful()) {
return true;
}
$which = new Process(['which', $cmd]);
$which->run();
return $which->isSuccessful();
}
private function cleanup(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$entries = glob($dir.DIRECTORY_SEPARATOR.'*');
if ($entries !== false) {
foreach ($entries as $entry) {
if (is_file($entry)) {
@unlink($entry);
}
}
}
@rmdir($dir);
}
private function formatSize(int $bytes): string
{
if ($bytes >= 1024 * 1024) {
return sprintf('%.1f MB', $bytes / 1024 / 1024);
}
if ($bytes >= 1024) {
return sprintf('%.1f KB', $bytes / 1024);
}
return $bytes.' B';
}
}