mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
wip
This commit is contained in:
29
bin/pest
29
bin/pest
@ -143,20 +143,6 @@ 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);
|
|
||||||
|
|
||||||
// Restarts PHP with `pcov.directory=<root>` when `--tia` is active and
|
|
||||||
// pcov is loaded, so the driver never instruments anything outside the
|
|
||||||
// project (vendor, system includes). Idempotent — guarded by an env
|
|
||||||
// sentinel so a single round-trip is enough.
|
|
||||||
\Pest\Support\PcovGuard::maybeRestart($rootPath);
|
|
||||||
|
|
||||||
$input = new ArgvInput;
|
$input = new ArgvInput;
|
||||||
|
|
||||||
$testSuite = TestSuite::getInstance(
|
$testSuite = TestSuite::getInstance(
|
||||||
@ -207,6 +193,21 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
try {
|
try {
|
||||||
$kernel = Kernel::boot($testSuite, $input, $output);
|
$kernel = Kernel::boot($testSuite, $input, $output);
|
||||||
|
|
||||||
|
// Restarters re-exec the PHP process when conditions warrant it
|
||||||
|
// (XdebugRestarter drops Xdebug on TIA replay; PcovRestarter pins
|
||||||
|
// `pcov.directory` to the project root). Runs here, after
|
||||||
|
// Kernel::boot has loaded `tests/Pest.php` (so
|
||||||
|
// `pest()->tia()->always()` is visible) and before any plugin's
|
||||||
|
// `handleArguments` runs (so a re-exec replays cleanly).
|
||||||
|
$container = \Pest\Support\Container::getInstance();
|
||||||
|
|
||||||
|
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||||
|
$restarter = $container->get($restarterClass);
|
||||||
|
assert($restarter instanceof \Pest\Contracts\Restarter);
|
||||||
|
|
||||||
|
$restarter->maybeRestart($rootPath, $originalArguments);
|
||||||
|
}
|
||||||
|
|
||||||
$result = $kernel->handle($originalArguments, $arguments);
|
$result = $kernel->handle($originalArguments, $arguments);
|
||||||
|
|
||||||
$kernel->terminate();
|
$kernel->terminate();
|
||||||
|
|||||||
@ -322,12 +322,13 @@ trait Testable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$recorder = Container::getInstance()->get(Recorder::class);
|
$recorder = Container::getInstance()->get(Recorder::class);
|
||||||
|
assert($recorder instanceof Recorder);
|
||||||
|
|
||||||
if ($recorder instanceof Recorder && $recorder->isActive()) {
|
if ($recorder->isActive()) {
|
||||||
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
$autoloadBeforeSetUp = $recorder instanceof Recorder && $recorder->isActive()
|
$autoloadBeforeSetUp = $recorder->isActive()
|
||||||
? AutoloadEdges::snapshot()
|
? AutoloadEdges::snapshot()
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@ -339,11 +340,9 @@ trait Testable
|
|||||||
// idempotent against the current app instance so the 774-test
|
// idempotent against the current app instance so the 774-test
|
||||||
// suite doesn't stack 774 composers / listeners when Laravel
|
// suite doesn't stack 774 composers / listeners when Laravel
|
||||||
// keeps the same app across tests.
|
// keeps the same app across tests.
|
||||||
if ($recorder instanceof Recorder) {
|
|
||||||
BladeEdges::arm($recorder);
|
BladeEdges::arm($recorder);
|
||||||
TableTracker::arm($recorder);
|
TableTracker::arm($recorder);
|
||||||
InertiaEdges::arm($recorder);
|
InertiaEdges::arm($recorder);
|
||||||
}
|
|
||||||
|
|
||||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||||
|
|
||||||
@ -353,7 +352,7 @@ trait Testable
|
|||||||
|
|
||||||
$this->__callClosure($beforeEach, $arguments);
|
$this->__callClosure($beforeEach, $arguments);
|
||||||
|
|
||||||
if ($recorder instanceof Recorder && $recorder->isActive() && $autoloadBeforeSetUp !== []) {
|
if ($recorder->isActive() && $autoloadBeforeSetUp !== []) {
|
||||||
$recorder->linkSourcesForTest(
|
$recorder->linkSourcesForTest(
|
||||||
self::$__filename,
|
self::$__filename,
|
||||||
AutoloadEdges::newProjectFiles(
|
AutoloadEdges::newProjectFiles(
|
||||||
|
|||||||
18
src/Contracts/Restarter.php
Normal file
18
src/Contracts/Restarter.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
interface Restarter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Re-execs the PHP process when conditions warrant it.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
public function maybeRestart(string $projectRoot, array $arguments): void;
|
||||||
|
}
|
||||||
@ -44,6 +44,18 @@ final readonly class Kernel
|
|||||||
Bootstrappers\BootExcludeList::class,
|
Bootstrappers\BootExcludeList::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Kernel restarters — resolved and invoked from `bin/pest`
|
||||||
|
* before any other Pest class is touched, so the list is exposed
|
||||||
|
* on the Kernel rather than driven from `bin/pest` directly.
|
||||||
|
*
|
||||||
|
* @var array<int, class-string<Contracts\Restarter>>
|
||||||
|
*/
|
||||||
|
public const array RESTARTERS = [
|
||||||
|
Restarters\XdebugRestarter::class,
|
||||||
|
Restarters\PcovRestarter::class,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Kernel instance.
|
* Creates a new Kernel instance.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -152,6 +152,42 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $this->state->write(self::KEY_GRAPH, $json);
|
return $this->state->write(self::KEY_GRAPH, $json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predicts whether TIA will activate for this run, *before* the Tia
|
||||||
|
* plugin's `handleArguments` runs. Mirrors the same gate the plugin
|
||||||
|
* itself applies: `--tia` on the CLI, or `pest()->tia()->always()`
|
||||||
|
* (optionally `->locally()`, which is honoured only outside CI).
|
||||||
|
*
|
||||||
|
* Used by the restarters in `bin/pest`, which fire after
|
||||||
|
* `Kernel::boot()` (so `tests/Pest.php` has populated WatchPatterns)
|
||||||
|
* but before any plugin's `handleArguments` runs.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
public static function isEnabledForRun(array $arguments): bool
|
||||||
|
{
|
||||||
|
if (in_array(self::OPTION, $arguments, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$watchPatterns = Container::getInstance()->get(Tia\WatchPatterns::class);
|
||||||
|
assert($watchPatterns instanceof Tia\WatchPatterns);
|
||||||
|
|
||||||
|
if (! $watchPatterns->isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `locally()` opts out on CI. Environment::name() reflects --ci
|
||||||
|
// only after Environment's own handleArguments has run, which
|
||||||
|
// hasn't happened at the restart-decision point — so check argv
|
||||||
|
// directly here.
|
||||||
|
if ($watchPatterns->isLocally() && in_array('--ci', $arguments, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCachedResult(string $filename, string $testId): ?TestStatus
|
public function getCachedResult(string $filename, string $testId): ?TestStatus
|
||||||
{
|
{
|
||||||
if (! $this->replayGraph instanceof Graph) {
|
if (! $this->replayGraph instanceof Graph) {
|
||||||
|
|||||||
@ -467,7 +467,7 @@ YAML;
|
|||||||
'gh', 'api',
|
'gh', 'api',
|
||||||
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
|
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
|
||||||
'--jq', sprintf(
|
'--jq', sprintf(
|
||||||
'.artifacts[] | select(.name == "%s") | .size_in_bytes',
|
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
|
||||||
self::ARTIFACT_NAME,
|
self::ARTIFACT_NAME,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|||||||
113
src/Restarters/PcovRestarter.php
Normal file
113
src/Restarters/PcovRestarter.php
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Restarters;
|
||||||
|
|
||||||
|
use Pest\Contracts\Restarter;
|
||||||
|
use Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-execs the PHP process with `pcov.directory` pinned to the project
|
||||||
|
* root so pcov never instruments anything outside it (vendor, system
|
||||||
|
* includes, etc.).
|
||||||
|
*
|
||||||
|
* pcov reads `pcov.directory` once, on the first file it instruments —
|
||||||
|
* setting it via `ini_set()` from inside the test runner is too late
|
||||||
|
* for files already compiled by Composer's autoloader. Restarting with
|
||||||
|
* `-dpcov.directory=<root>` means *every* file pcov sees is filtered
|
||||||
|
* correctly.
|
||||||
|
*
|
||||||
|
* Only fires when ALL of these hold:
|
||||||
|
* 1. The pcov extension is loaded.
|
||||||
|
* 2. TIA is enabled for this run (see {@see Tia::isEnabledForRun()} —
|
||||||
|
* either `--tia` on the CLI or `pest()->tia()->always()`); plain
|
||||||
|
* `pest` runs are unaffected.
|
||||||
|
* 3. The current `pcov.directory` differs from the project root.
|
||||||
|
* 4. We are not already the restarted process — guarded by an env
|
||||||
|
* sentinel so a single round-trip is enough.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class PcovRestarter implements Restarter
|
||||||
|
{
|
||||||
|
private const string ENV_RESTARTED = 'PEST_PCOV_RESTARTER_RESTARTED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
public function maybeRestart(string $projectRoot, array $arguments): void
|
||||||
|
{
|
||||||
|
if (! extension_loaded('pcov')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getenv(self::ENV_RESTARTED) === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Tia::isEnabledForRun($arguments)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$desired = $this->normalise($projectRoot);
|
||||||
|
$current = $this->normalise((string) ini_get('pcov.directory'));
|
||||||
|
|
||||||
|
if ($current === $desired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->restart($projectRoot, $arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
private function restart(string $projectRoot, array $arguments): void
|
||||||
|
{
|
||||||
|
$env = $this->inheritEnv();
|
||||||
|
$env[self::ENV_RESTARTED] = '1';
|
||||||
|
|
||||||
|
$command = array_merge(
|
||||||
|
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
|
||||||
|
array_values($arguments),
|
||||||
|
);
|
||||||
|
|
||||||
|
$proc = @proc_open(
|
||||||
|
$command,
|
||||||
|
[STDIN, STDOUT, STDERR],
|
||||||
|
$pipes,
|
||||||
|
null,
|
||||||
|
$env,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! is_resource($proc)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exitCode = proc_close($proc);
|
||||||
|
|
||||||
|
exit($exitCode === -1 ? 1 : $exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function inheritEnv(): array
|
||||||
|
{
|
||||||
|
$env = [];
|
||||||
|
|
||||||
|
foreach (getenv() as $name => $value) {
|
||||||
|
if (is_string($name) && is_string($value)) {
|
||||||
|
$env[$name] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $env;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalise(string $path): string
|
||||||
|
{
|
||||||
|
return rtrim($path, '/\\');
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/Restarters/XdebugRestarter.php
Normal file
130
src/Restarters/XdebugRestarter.php
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Restarters;
|
||||||
|
|
||||||
|
use Composer\XdebugHandler\XdebugHandler;
|
||||||
|
use Pest\Contracts\Restarter;
|
||||||
|
use Pest\Plugins\Tia;
|
||||||
|
use Pest\Plugins\Tia\Fingerprint;
|
||||||
|
use Pest\Plugins\Tia\Graph;
|
||||||
|
use Pest\Plugins\Tia\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class XdebugRestarter implements Restarter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
public function maybeRestart(string $projectRoot, array $arguments): void
|
||||||
|
{
|
||||||
|
if (! class_exists(XdebugHandler::class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! extension_loaded('xdebug')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->xdebugIsCoverageOnly()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->runLooksDroppable($arguments, $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 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'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TIA must be enabled for this run, no coverage flag, no forced
|
||||||
|
* rebuild, and TIA must be about to replay rather than record. Plain
|
||||||
|
* `pest` (and anything else without TIA enabled) keeps Xdebug loaded
|
||||||
|
* so non-TIA users aren't surprised by behaviour changes.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
private function runLooksDroppable(array $arguments, string $projectRoot): bool
|
||||||
|
{
|
||||||
|
foreach ($arguments as $value) {
|
||||||
|
if ($value === '--coverage'
|
||||||
|
|| str_starts_with($value, '--coverage=')
|
||||||
|
|| str_starts_with($value, '--coverage-')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === '--fresh') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Tia::isEnabledForRun($arguments)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->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 function tiaWillReplay(string $projectRoot): bool
|
||||||
|
{
|
||||||
|
$path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,182 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Support;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-execs the PHP process with `pcov.directory` pinned to the project
|
|
||||||
* root so pcov never instruments anything outside it (vendor, system
|
|
||||||
* includes, etc.).
|
|
||||||
*
|
|
||||||
* pcov reads `pcov.directory` once, on the first file it instruments —
|
|
||||||
* setting it via `ini_set()` from inside the test runner is too late
|
|
||||||
* for files already compiled by Composer's autoloader. Restarting the
|
|
||||||
* process with `-dpcov.directory=<root>` from the very top of `bin/pest`
|
|
||||||
* means *every* file pcov sees is filtered correctly.
|
|
||||||
*
|
|
||||||
* Only fires when ALL of these hold:
|
|
||||||
* 1. The pcov extension is loaded.
|
|
||||||
* 2. `--tia` is present in argv (plain `pest` runs are unaffected).
|
|
||||||
* 3. The current `pcov.directory` differs from the project root.
|
|
||||||
* 4. We are not already the restarted process — guarded by an env
|
|
||||||
* sentinel so a single round-trip is enough.
|
|
||||||
*
|
|
||||||
* Modelled after {@see XdebugGuard}: the same "check before doing real
|
|
||||||
* work in `bin/pest`" position, the same conservative gating around
|
|
||||||
* `--tia`. They are independent — both can fire on the same invocation
|
|
||||||
* (the user has pcov *and* xdebug loaded), in which case Xdebug is
|
|
||||||
* dropped first and the pcov restart inherits the slimmer process.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class PcovGuard
|
|
||||||
{
|
|
||||||
private const string ENV_RESTARTED = 'PEST_PCOV_GUARD_RESTARTED';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call as early as possible after Composer autoload, before any
|
|
||||||
* Pest class beyond the autoloader is touched. Idempotent and
|
|
||||||
* defensive — returns silently when pcov isn't installed, when the
|
|
||||||
* INI is already correct, or when we've already restarted.
|
|
||||||
*/
|
|
||||||
public static function maybeRestart(string $projectRoot): void
|
|
||||||
{
|
|
||||||
if (! extension_loaded('pcov')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getenv(self::ENV_RESTARTED) === '1') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$argv = is_array($_SERVER['argv'] ?? null) ? $_SERVER['argv'] : [];
|
|
||||||
|
|
||||||
if (! self::hasTiaFlag($argv)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$desired = self::normalise($projectRoot);
|
|
||||||
$current = self::normalise((string) ini_get('pcov.directory'));
|
|
||||||
|
|
||||||
if ($current === $desired) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self::restart($projectRoot, $argv);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, mixed> $argv
|
|
||||||
*/
|
|
||||||
private static function hasTiaFlag(array $argv): bool
|
|
||||||
{
|
|
||||||
foreach ($argv as $value) {
|
|
||||||
if (is_string($value) && $value === '--tia') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawns a child PHP process inheriting our stdin/stdout/stderr and
|
|
||||||
* exits with its status. `pcntl_exec` would be the cleanest path
|
|
||||||
* (replaces the current process, no double-buffering) but it isn't
|
|
||||||
* available on Windows or in environments that disable it; the
|
|
||||||
* `proc_open` fallback works everywhere PHP runs.
|
|
||||||
*
|
|
||||||
* @param array<int, mixed> $argv
|
|
||||||
*/
|
|
||||||
private static function restart(string $projectRoot, array $argv): void
|
|
||||||
{
|
|
||||||
$script = self::scriptArgv($argv);
|
|
||||||
|
|
||||||
if ($script === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$env = self::inheritEnv();
|
|
||||||
$env[self::ENV_RESTARTED] = '1';
|
|
||||||
|
|
||||||
$command = array_merge(
|
|
||||||
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
|
|
||||||
$script,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (function_exists('pcntl_exec')) {
|
|
||||||
// `pcntl_exec` returns false on failure and replaces the
|
|
||||||
// process on success — no `exit` needed in the success path.
|
|
||||||
// Pass the env explicitly because pcntl_exec doesn't inherit
|
|
||||||
// by default.
|
|
||||||
$binary = array_shift($command);
|
|
||||||
|
|
||||||
if (is_string($binary)) {
|
|
||||||
@pcntl_exec($binary, $command, $env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're still here, pcntl_exec failed; fall through.
|
|
||||||
}
|
|
||||||
|
|
||||||
$proc = @proc_open(
|
|
||||||
$command,
|
|
||||||
[STDIN, STDOUT, STDERR],
|
|
||||||
$pipes,
|
|
||||||
null,
|
|
||||||
$env,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! is_resource($proc)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$exitCode = proc_close($proc);
|
|
||||||
|
|
||||||
exit($exitCode === -1 ? 1 : $exitCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconstructs the argv we want the child process to receive: the
|
|
||||||
* script path followed by every original argument. Returns null
|
|
||||||
* when argv is malformed and we can't safely restart.
|
|
||||||
*
|
|
||||||
* @param array<int, mixed> $argv
|
|
||||||
* @return list<string>|null
|
|
||||||
*/
|
|
||||||
private static function scriptArgv(array $argv): ?array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($argv as $value) {
|
|
||||||
if (! is_string($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[] = $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out === [] ? null : $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private static function inheritEnv(): array
|
|
||||||
{
|
|
||||||
$env = [];
|
|
||||||
|
|
||||||
foreach (getenv() as $name => $value) {
|
|
||||||
if (is_string($name) && is_string($value)) {
|
|
||||||
$env[$name] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $env;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function normalise(string $path): string
|
|
||||||
{
|
|
||||||
return rtrim($path, '/\\');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user