mirror of
https://github.com/pestphp/pest.git
synced 2026-04-21 22:47:27 +02:00
wip
This commit is contained in:
@ -70,7 +70,12 @@ final readonly class Kernel
|
|||||||
->add(Tia\CoverageCollector::class, new Tia\CoverageCollector)
|
->add(Tia\CoverageCollector::class, new Tia\CoverageCollector)
|
||||||
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
|
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
|
||||||
->add(Tia\ResultCollector::class, new Tia\ResultCollector)
|
->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(
|
$kernel = new self(
|
||||||
new Application,
|
new Application,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ use Pest\Contracts\Plugins\AddsOutput;
|
|||||||
use Pest\Contracts\Plugins\HandlesArguments;
|
use Pest\Contracts\Plugins\HandlesArguments;
|
||||||
use Pest\Contracts\Plugins\Terminable;
|
use Pest\Contracts\Plugins\Terminable;
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
|
use Pest\Plugins\Tia\BaselineSync;
|
||||||
use Pest\Plugins\Tia\ChangedFiles;
|
use Pest\Plugins\Tia\ChangedFiles;
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
use Pest\Plugins\Tia\CoverageCollector;
|
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 REBUILD_OPTION = '--tia-rebuild';
|
||||||
|
|
||||||
|
private const string PUBLISH_OPTION = '--tia-publish';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@ -195,6 +198,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
private readonly CoverageCollector $coverageCollector,
|
private readonly CoverageCollector $coverageCollector,
|
||||||
private readonly WatchPatterns $watchPatterns,
|
private readonly WatchPatterns $watchPatterns,
|
||||||
private readonly State $state,
|
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';
|
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
|
||||||
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_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);
|
$enabled = $this->hasArgument(self::OPTION, $arguments);
|
||||||
$forceRebuild = $this->hasArgument(self::REBUILD_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
|
// Drop the marker so `Support\Coverage::report()` knows to merge the
|
||||||
// current (narrow) coverage with the cached full-run snapshot. Plain
|
// current (narrow) coverage with the cached full-run snapshot. Plain
|
||||||
// `--coverage` runs don't drop it, so their behaviour is untouched.
|
// `--coverage` runs don't drop it, so their behaviour is untouched.
|
||||||
|
|||||||
524
src/Plugins/Tia/BaselineSync.php
Normal file
524
src/Plugins/Tia/BaselineSync.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user