From bc7bbc49b4ba6a761d62a601a923dee5d835fd30 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Tue, 29 Sep 2026 17:05:42 +0100 Subject: [PATCH 1/2] fix: allow --init without a tests directory Boot aborted before TIA state was registered, and shutdown then tried to construct the plugin. That hid the real error behind "State cannot be resolved" on a fresh install. Closes #1934 --- src/Bootstrappers/BootFiles.php | 18 +++++ src/Kernel.php | 8 +- tests/Features/Init.php | 135 ++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/Features/Init.php diff --git a/src/Bootstrappers/BootFiles.php b/src/Bootstrappers/BootFiles.php index d8848fe07..1debd9c10 100644 --- a/src/Bootstrappers/BootFiles.php +++ b/src/Bootstrappers/BootFiles.php @@ -33,6 +33,10 @@ final class BootFiles implements Bootstrapper public function boot(): void { + if ($this->runningInit()) { + return; + } + $rootPath = TestSuite::getInstance()->rootPath; $testsPath = $rootPath.DIRECTORY_SEPARATOR.testDirectory(); @@ -62,6 +66,20 @@ public function boot(): void $this->bootDatasets($testsPath); } + /** + * `--init` creates the tests directory, so it cannot be required yet. + */ + private function runningInit(): bool + { + $arguments = $_SERVER['argv'] ?? null; + + if (! is_array($arguments)) { + return false; + } + + return ($arguments[1] ?? null) === '--init'; + } + private function load(string $filename): void { if (! Str::endsWith($filename, '.php')) { diff --git a/src/Kernel.php b/src/Kernel.php index 9348cdaf8..ecf0b7a6b 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -31,6 +31,8 @@ final class Kernel { private bool $terminated = false; + private bool $booted = false; + /** * @var array */ @@ -81,6 +83,8 @@ public static function boot(TestSuite $testSuite, InputInterface $input, OutputI CallsBoot::execute(); + $kernel->booted = true; + Container::getInstance()->add(self::class, $kernel); return $kernel; @@ -137,7 +141,9 @@ public function terminate(): void public function shutdown(): void { - $this->terminate(); + if ($this->booted) { + $this->terminate(); + } if (is_array($error = error_get_last())) { if (! in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) { diff --git a/tests/Features/Init.php b/tests/Features/Init.php new file mode 100644 index 000000000..1c72fe68b --- /dev/null +++ b/tests/Features/Init.php @@ -0,0 +1,135 @@ +getExitCode())->toBe(0) + ->and($process->getErrorOutput())->not->toContain('cannot be resolved') + ->and($process->getOutput())->toContain('File created.') + ->and(is_file($root.DIRECTORY_SEPARATOR.'phpunit.xml'))->toBeTrue() + ->and(is_file($root.DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.'Pest.php'))->toBeTrue() + ->and(is_file($root.DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.'TestCase.php'))->toBeTrue() + ->and(is_file($root.DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.'Unit'.DIRECTORY_SEPARATOR.'ExampleTest.php'))->toBeTrue(); + } finally { + removeInitProject($root); + } +}); + +test('a missing tests directory does not crash while resolving tia state', function (): void { + $root = makeInitProject(); + + try { + $process = runInitProject($root, '--version'); + + expect($process->getExitCode())->not->toBe(0) + ->and($process->getOutput())->toContain('does not exist') + ->and($process->getErrorOutput())->not->toContain('cannot be resolved') + ->and($process->getOutput())->not->toContain('cannot be resolved'); + } finally { + removeInitProject($root); + } +}); + +function makeInitProject(): string +{ + $root = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-init-'.bin2hex(random_bytes(4)); + + mkdir($root.DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'bin', 0777, true); + + copy( + dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'pest-plugins.json', + $root.DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'pest-plugins.json', + ); + + $autoload = var_export( + dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'autoload.php', + true, + ); + + file_put_contents($root.DIRECTORY_SEPARATOR.'run.php', <<handle(\$_SERVER['argv'], \$_SERVER['argv']); + \$kernel->terminate(); +} catch (Throwable \$throwable) { + Panic::with(\$throwable); +} + +exit(\$result); +PHP); + + return $root; +} + +function runInitProject(string $root, string $command): Process +{ + $process = new Process( + [PHP_BINARY, 'run.php', $command], + $root, + [ + 'PEST_NO_SUPPORT' => 'true', + 'COLLISION_PRINTER' => 'DefaultPrinter', + 'COLLISION_IGNORE_DURATION' => 'true', + ], + ); + + $process->setTimeout(60); + $process->run(); + + return $process; +} + +function removeInitProject(string $root): void +{ + if (! is_dir($root)) { + return; + } + + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + if ($item->isDir() && ! $item->isLink()) { + rmdir($item->getPathname()); + + continue; + } + + unlink($item->getPathname()); + } + + rmdir($root); +} From 7d40cb6d32ae98e3a1426b30df55a1d304572154 Mon Sep 17 00:00:00 2001 From: Tiago Santos Date: Tue, 6 Oct 2026 15:17:33 +0100 Subject: [PATCH 2/2] fix: detect --init anywhere in the arguments Flags before --init were still treated as a normal run and failed when tests/ did not exist. --- src/Bootstrappers/BootFiles.php | 2 +- src/Plugins/Init.php | 9 +++------ tests/Features/Init.php | 30 +++++++++++++++++++++++++----- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/Bootstrappers/BootFiles.php b/src/Bootstrappers/BootFiles.php index 1debd9c10..a0bc76100 100644 --- a/src/Bootstrappers/BootFiles.php +++ b/src/Bootstrappers/BootFiles.php @@ -77,7 +77,7 @@ private function runningInit(): bool return false; } - return ($arguments[1] ?? null) === '--init'; + return in_array('--init', $arguments, true); } private function load(string $filename): void diff --git a/src/Plugins/Init.php b/src/Plugins/Init.php index fe0105bb7..d5aea2bec 100644 --- a/src/Plugins/Init.php +++ b/src/Plugins/Init.php @@ -17,6 +17,8 @@ */ final readonly class Init implements HandlesArguments { + use Concerns\HandleArguments; + private const string INIT_OPTION = '--init'; private const array STUBS = [ @@ -40,15 +42,10 @@ public function __construct( */ public function handleArguments(array $arguments): array { - if (! array_key_exists(1, $arguments)) { - return $arguments; - } - if ($arguments[1] !== self::INIT_OPTION) { + if (! $this->hasArgument(self::INIT_OPTION, $arguments)) { return $arguments; } - unset($arguments[1]); - $this->init(); exit(0); diff --git a/tests/Features/Init.php b/tests/Features/Init.php index 1c72fe68b..80c1d8bf5 100644 --- a/tests/Features/Init.php +++ b/tests/Features/Init.php @@ -22,6 +22,22 @@ } }); +test('init creates a suite when --init is not the first argument', function (): void { + $root = makeInitProject(); + + try { + $process = runInitProject($root, '--colors=never', '--init'); + + expect($process->getExitCode())->toBe(0) + ->and($process->getErrorOutput())->not->toContain('cannot be resolved') + ->and($process->getOutput())->toContain('File created.') + ->and(is_file($root.DIRECTORY_SEPARATOR.'phpunit.xml'))->toBeTrue() + ->and(is_file($root.DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR.'Pest.php'))->toBeTrue(); + } finally { + removeInitProject($root); + } +}); + test('a missing tests directory does not crash while resolving tia state', function (): void { $root = makeInitProject(); @@ -66,14 +82,18 @@ function makeInitProject(): string require {$autoload}; -\$command = \$argv[1] ?? '--version'; +\$arguments = array_slice(\$argv, 1); + +if (\$arguments === []) { + \$arguments = ['--version']; +} \$GLOBALS['_composer_bin_dir'] = getcwd().'/vendor/bin'; -\$_SERVER['argv'] = ['pest', \$command]; +\$_SERVER['argv'] = ['pest', ...\$arguments]; \$_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter'; -\$input = new ArgvInput(['pest', \$command]); +\$input = new ArgvInput(\$_SERVER['argv']); \$output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, false); \$testSuite = TestSuite::getInstance(getcwd(), 'tests'); @@ -92,10 +112,10 @@ function makeInitProject(): string return $root; } -function runInitProject(string $root, string $command): Process +function runInitProject(string $root, string ...$command): Process { $process = new Process( - [PHP_BINARY, 'run.php', $command], + [PHP_BINARY, 'run.php', ...$command], $root, [ 'PEST_NO_SUPPORT' => 'true',