From 45b1d4ce20828a9747fedab09263a73ec0a1a537 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Fri, 1 May 2026 19:50:54 +0100 Subject: [PATCH] wip --- bin/pest | 29 ++--- src/Concerns/Testable.php | 15 ++- src/Contracts/Restarter.php | 18 +++ src/Kernel.php | 12 ++ src/Plugins/Tia.php | 36 ++++++ src/Plugins/Tia/BaselineSync.php | 2 +- src/Restarters/PcovRestarter.php | 113 ++++++++++++++++++ src/Restarters/XdebugRestarter.php | 130 +++++++++++++++++++++ src/Support/PcovGuard.php | 182 ----------------------------- src/Support/XdebugGuard.php | 178 ---------------------------- 10 files changed, 332 insertions(+), 383 deletions(-) create mode 100644 src/Contracts/Restarter.php create mode 100644 src/Restarters/PcovRestarter.php create mode 100644 src/Restarters/XdebugRestarter.php delete mode 100644 src/Support/PcovGuard.php delete mode 100644 src/Support/XdebugGuard.php diff --git a/bin/pest b/bin/pest index 5c34c8c6..0dcde19c 100755 --- a/bin/pest +++ b/bin/pest @@ -143,20 +143,6 @@ use Symfony\Component\Console\Output\ConsoleOutput; // Get $rootPath based on $autoloadPath $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=` 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; $testSuite = TestSuite::getInstance( @@ -207,6 +193,21 @@ use Symfony\Component\Console\Output\ConsoleOutput; try { $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); $kernel->terminate(); diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 22491ad8..ced76540 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -322,12 +322,13 @@ trait Testable } $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); } - $autoloadBeforeSetUp = $recorder instanceof Recorder && $recorder->isActive() + $autoloadBeforeSetUp = $recorder->isActive() ? AutoloadEdges::snapshot() : []; @@ -339,11 +340,9 @@ trait Testable // idempotent against the current app instance so the 774-test // suite doesn't stack 774 composers / listeners when Laravel // keeps the same app across tests. - if ($recorder instanceof Recorder) { - BladeEdges::arm($recorder); - TableTracker::arm($recorder); - InertiaEdges::arm($recorder); - } + BladeEdges::arm($recorder); + TableTracker::arm($recorder); + InertiaEdges::arm($recorder); $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; @@ -353,7 +352,7 @@ trait Testable $this->__callClosure($beforeEach, $arguments); - if ($recorder instanceof Recorder && $recorder->isActive() && $autoloadBeforeSetUp !== []) { + if ($recorder->isActive() && $autoloadBeforeSetUp !== []) { $recorder->linkSourcesForTest( self::$__filename, AutoloadEdges::newProjectFiles( diff --git a/src/Contracts/Restarter.php b/src/Contracts/Restarter.php new file mode 100644 index 00000000..34a91fdc --- /dev/null +++ b/src/Contracts/Restarter.php @@ -0,0 +1,18 @@ + $arguments + */ + public function maybeRestart(string $projectRoot, array $arguments): void; +} diff --git a/src/Kernel.php b/src/Kernel.php index aef9bda7..0bf3ba3a 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -44,6 +44,18 @@ final readonly class Kernel 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> + */ + public const array RESTARTERS = [ + Restarters\XdebugRestarter::class, + Restarters\PcovRestarter::class, + ]; + /** * Creates a new Kernel instance. */ diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index e8b41f48..09f147b5 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -152,6 +152,42 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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 $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 { if (! $this->replayGraph instanceof Graph) { diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 847a1d75..2cfa7665 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -467,7 +467,7 @@ YAML; 'gh', 'api', sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId), '--jq', sprintf( - '.artifacts[] | select(.name == "%s") | .size_in_bytes', + '.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type self::ARTIFACT_NAME, ), ]); diff --git a/src/Restarters/PcovRestarter.php b/src/Restarters/PcovRestarter.php new file mode 100644 index 00000000..4fbc3efc --- /dev/null +++ b/src/Restarters/PcovRestarter.php @@ -0,0 +1,113 @@ +` 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 $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 $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 + */ + 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, '/\\'); + } +} diff --git a/src/Restarters/XdebugRestarter.php b/src/Restarters/XdebugRestarter.php new file mode 100644 index 00000000..905a132a --- /dev/null +++ b/src/Restarters/XdebugRestarter.php @@ -0,0 +1,130 @@ + $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 $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), + ); + } +} diff --git a/src/Support/PcovGuard.php b/src/Support/PcovGuard.php deleted file mode 100644 index 7ba6dcf2..00000000 --- a/src/Support/PcovGuard.php +++ /dev/null @@ -1,182 +0,0 @@ -` 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 $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 $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 $argv - * @return list|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 - */ - 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, '/\\'); - } -} diff --git a/src/Support/XdebugGuard.php b/src/Support/XdebugGuard.php deleted file mode 100644 index de1c15da..00000000 --- a/src/Support/XdebugGuard.php +++ /dev/null @@ -1,178 +0,0 @@ -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 $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; - } -}