diff --git a/.psalm/baseline.xml b/.psalm/baseline.xml index e42cc8777a7..c6595f1b71a 100644 --- a/.psalm/baseline.xml +++ b/.psalm/baseline.xml @@ -378,11 +378,7 @@ - - - getMessage()]]> - output()]]> - + @@ -390,6 +386,12 @@ + + + getMessage()]]> + output()]]> + + name]]> @@ -656,7 +658,7 @@ - + diff --git a/src/Framework/TestCase.php b/src/Framework/TestCase.php index 8e4f1c5161e..cf1e781cbec 100644 --- a/src/Framework/TestCase.php +++ b/src/Framework/TestCase.php @@ -347,13 +347,15 @@ final public function run(): void if (!$this->shouldRunInSeparateProcess()) { (new TestRunner)->run($this); - } else { - (new TestRunner)->runInSeparateProcess( - $this, - $this->runClassInSeparateProcess && !$this->runTestInSeparateProcess, - $this->preserveGlobalState, - ); + + return; } + + IsolatedTestRunnerRegistry::run( + $this, + $this->runClassInSeparateProcess && !$this->runTestInSeparateProcess, + $this->preserveGlobalState, + ); } /** diff --git a/src/Framework/TestRunner/IsolatedTestRunner.php b/src/Framework/TestRunner/IsolatedTestRunner.php new file mode 100644 index 00000000000..9e798afd966 --- /dev/null +++ b/src/Framework/TestRunner/IsolatedTestRunner.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework; + +/** + * @internal This interface is not covered by the backward compatibility promise for PHPUnit + */ +interface IsolatedTestRunner +{ + public function run(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void; +} diff --git a/src/Framework/TestRunner/IsolatedTestRunnerRegistry.php b/src/Framework/TestRunner/IsolatedTestRunnerRegistry.php new file mode 100644 index 00000000000..5b2070cab7a --- /dev/null +++ b/src/Framework/TestRunner/IsolatedTestRunnerRegistry.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework; + +/** + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class IsolatedTestRunnerRegistry +{ + private static ?IsolatedTestRunner $runner = null; + + public static function run(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void + { + if (self::$runner === null) { + self::$runner = new SeparateProcessTestRunner; + } + + self::$runner->run($test, $runEntireClass, $preserveGlobalState); + } + + public static function set(IsolatedTestRunner $runner): void + { + self::$runner = $runner; + } +} diff --git a/src/Framework/TestRunner/SeparateProcessTestRunner.php b/src/Framework/TestRunner/SeparateProcessTestRunner.php new file mode 100644 index 00000000000..ec82a791cf3 --- /dev/null +++ b/src/Framework/TestRunner/SeparateProcessTestRunner.php @@ -0,0 +1,288 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework; + +use function assert; +use function defined; +use function file_exists; +use function file_get_contents; +use function get_include_path; +use function hrtime; +use function restore_error_handler; +use function serialize; +use function set_error_handler; +use function sys_get_temp_dir; +use function tempnam; +use function trim; +use function unlink; +use function unserialize; +use function var_export; +use ErrorException; +use PHPUnit\Event\Code\TestMethodBuilder; +use PHPUnit\Event\Code\ThrowableBuilder; +use PHPUnit\Event\Facade; +use PHPUnit\Event\NoPreviousThrowableException; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; +use PHPUnit\Util\GlobalState; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunnerRegistry; +use PHPUnit\Util\PHP\PhpProcessException; +use ReflectionClass; +use SebastianBergmann\CodeCoverage\StaticAnalysisCacheNotConfiguredException; +use SebastianBergmann\Template\InvalidArgumentException; +use SebastianBergmann\Template\Template; + +/** + * @internal This interface is not covered by the backward compatibility promise for PHPUnit + */ +final class SeparateProcessTestRunner implements IsolatedTestRunner +{ + /** + * @throws \PHPUnit\Runner\Exception + * @throws \PHPUnit\Util\Exception + * @throws Exception + * @throws InvalidArgumentException + * @throws NoPreviousThrowableException + * @throws ProcessIsolationException + * @throws StaticAnalysisCacheNotConfiguredException + */ + public function run(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void + { + $class = new ReflectionClass($test); + + if ($runEntireClass) { + $template = new Template( + __DIR__ . '/templates/class.tpl', + ); + } else { + $template = new Template( + __DIR__ . '/templates/method.tpl', + ); + } + + $bootstrap = ''; + $constants = ''; + $globals = ''; + $includedFiles = ''; + $iniSettings = ''; + + if (ConfigurationRegistry::get()->hasBootstrap()) { + $bootstrap = ConfigurationRegistry::get()->bootstrap(); + } + + if ($preserveGlobalState) { + $constants = GlobalState::getConstantsAsString(); + $globals = GlobalState::getGlobalsAsString(); + $includedFiles = GlobalState::getIncludedFilesAsString(); + $iniSettings = GlobalState::getIniSettingsAsString(); + } + + $coverage = CodeCoverage::instance()->isActive() ? 'true' : 'false'; + $linesToBeIgnored = var_export(CodeCoverage::instance()->linesToBeIgnored(), true); + + if (defined('PHPUNIT_COMPOSER_INSTALL')) { + $composerAutoload = var_export(PHPUNIT_COMPOSER_INSTALL, true); + } else { + $composerAutoload = '\'\''; + } + + if (defined('__PHPUNIT_PHAR__')) { + $phar = var_export(__PHPUNIT_PHAR__, true); + } else { + $phar = '\'\''; + } + + $data = var_export(serialize($test->providedData()), true); + $dataName = var_export($test->dataName(), true); + $dependencyInput = var_export(serialize($test->dependencyInput()), true); + $includePath = var_export(get_include_path(), true); + // must do these fixes because TestCaseMethod.tpl has unserialize('{data}') in it, and we can't break BC + // the lines above used to use addcslashes() rather than var_export(), which breaks null byte escape sequences + $data = "'." . $data . ".'"; + $dataName = "'.(" . $dataName . ").'"; + $dependencyInput = "'." . $dependencyInput . ".'"; + $includePath = "'." . $includePath . ".'"; + $offset = hrtime(); + $serializedConfiguration = $this->saveConfigurationForChildProcess(); + $processResultFile = tempnam(sys_get_temp_dir(), 'phpunit_'); + + $var = [ + 'bootstrap' => $bootstrap, + 'composerAutoload' => $composerAutoload, + 'phar' => $phar, + 'filename' => $class->getFileName(), + 'className' => $class->getName(), + 'collectCodeCoverageInformation' => $coverage, + 'linesToBeIgnored' => $linesToBeIgnored, + 'data' => $data, + 'dataName' => $dataName, + 'dependencyInput' => $dependencyInput, + 'constants' => $constants, + 'globals' => $globals, + 'include_path' => $includePath, + 'included_files' => $includedFiles, + 'iniSettings' => $iniSettings, + 'name' => $test->name(), + 'offsetSeconds' => $offset[0], + 'offsetNanoseconds' => $offset[1], + 'serializedConfiguration' => $serializedConfiguration, + 'processResultFile' => $processResultFile, + ]; + + if (!$runEntireClass) { + $var['methodName'] = $test->name(); + } + + $template->setVar($var); + + $code = $template->render(); + + assert($code !== ''); + + $this->runTestJob($code, $test, $processResultFile); + + @unlink($serializedConfiguration); + } + + /** + * @psalm-param non-empty-string $code + * + * @throws Exception + * @throws NoPreviousThrowableException + * @throws PhpProcessException + */ + private function runTestJob(string $code, Test $test, string $processResultFile): void + { + $result = JobRunnerRegistry::run(new Job($code)); + + $processResult = ''; + + if (file_exists($processResultFile)) { + $processResult = file_get_contents($processResultFile); + + @unlink($processResultFile); + } + + $this->processChildResult( + $test, + $processResult, + $result->stderr(), + ); + } + + /** + * @throws Exception + * @throws NoPreviousThrowableException + */ + private function processChildResult(Test $test, string $stdout, string $stderr): void + { + if (!empty($stderr)) { + $exception = new Exception(trim($stderr)); + + assert($test instanceof TestCase); + + Facade::emitter()->testErrored( + TestMethodBuilder::fromTestCase($test), + ThrowableBuilder::from($exception), + ); + + return; + } + + set_error_handler( + /** + * @throws ErrorException + */ + static function (int $errno, string $errstr, string $errfile, int $errline): never + { + throw new ErrorException($errstr, $errno, $errno, $errfile, $errline); + }, + ); + + try { + $childResult = unserialize($stdout); + + restore_error_handler(); + + if ($childResult === false) { + $exception = new AssertionFailedError('Test was run in child process and ended unexpectedly'); + + assert($test instanceof TestCase); + + Facade::emitter()->testErrored( + TestMethodBuilder::fromTestCase($test), + ThrowableBuilder::from($exception), + ); + + Facade::emitter()->testFinished( + TestMethodBuilder::fromTestCase($test), + 0, + ); + } + } catch (ErrorException $e) { + restore_error_handler(); + + $childResult = false; + + $exception = new Exception(trim($stdout), 0, $e); + + assert($test instanceof TestCase); + + Facade::emitter()->testErrored( + TestMethodBuilder::fromTestCase($test), + ThrowableBuilder::from($exception), + ); + } + + if ($childResult !== false) { + if (!empty($childResult['output'])) { + $output = $childResult['output']; + } + + Facade::instance()->forward($childResult['events']); + PassedTests::instance()->import($childResult['passedTests']); + + assert($test instanceof TestCase); + + $test->setResult($childResult['testResult']); + $test->addToAssertionCount($childResult['numAssertions']); + + if (CodeCoverage::instance()->isActive() && $childResult['codeCoverage'] instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { + CodeCoverage::instance()->codeCoverage()->merge( + $childResult['codeCoverage'], + ); + } + } + + if (!empty($output)) { + print $output; + } + } + + /** + * @throws ProcessIsolationException + */ + private function saveConfigurationForChildProcess(): string + { + $path = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($path === false) { + throw new ProcessIsolationException; + } + + if (!ConfigurationRegistry::saveTo($path)) { + throw new ProcessIsolationException; + } + + return $path; + } +} diff --git a/src/Framework/TestRunner.php b/src/Framework/TestRunner/TestRunner.php similarity index 53% rename from src/Framework/TestRunner.php rename to src/Framework/TestRunner/TestRunner.php index 32801bcb753..4f6fa14ee1c 100644 --- a/src/Framework/TestRunner.php +++ b/src/Framework/TestRunner/TestRunner.php @@ -11,47 +11,22 @@ use const PHP_EOL; use function assert; -use function defined; use function extension_loaded; -use function file_exists; -use function file_get_contents; -use function get_include_path; -use function hrtime; -use function restore_error_handler; -use function serialize; -use function set_error_handler; use function sprintf; -use function sys_get_temp_dir; -use function tempnam; -use function trim; -use function unlink; -use function unserialize; -use function var_export; use AssertionError; -use ErrorException; -use PHPUnit\Event\Code\TestMethodBuilder; -use PHPUnit\Event\Code\ThrowableBuilder; use PHPUnit\Event\Facade; -use PHPUnit\Event\NoPreviousThrowableException; use PHPUnit\Metadata\Api\CodeCoverage as CodeCoverageMetadataApi; use PHPUnit\Metadata\Parser\Registry as MetadataRegistry; use PHPUnit\Runner\CodeCoverage; use PHPUnit\Runner\ErrorHandler; -use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\Runner\Exception; use PHPUnit\TextUI\Configuration\Configuration; use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; -use PHPUnit\Util\GlobalState; -use PHPUnit\Util\PHP\Job; -use PHPUnit\Util\PHP\JobRunnerRegistry; -use PHPUnit\Util\PHP\PhpProcessException; -use ReflectionClass; use SebastianBergmann\CodeCoverage\Exception as OriginalCodeCoverageException; use SebastianBergmann\CodeCoverage\InvalidArgumentException; -use SebastianBergmann\CodeCoverage\StaticAnalysisCacheNotConfiguredException; use SebastianBergmann\CodeCoverage\UnintentionallyCoveredCodeException; use SebastianBergmann\Invoker\Invoker; use SebastianBergmann\Invoker\TimeoutException; -use SebastianBergmann\Template\Template; use Throwable; /** @@ -68,8 +43,8 @@ public function __construct() } /** - * @throws \PHPUnit\Runner\Exception * @throws CodeCoverageException + * @throws Exception * @throws InvalidArgumentException * @throws UnintentionallyCoveredCodeException */ @@ -250,113 +225,6 @@ public function run(TestCase $test): void } } - /** - * @throws \PHPUnit\Runner\Exception - * @throws \PHPUnit\Util\Exception - * @throws \SebastianBergmann\Template\InvalidArgumentException - * @throws Exception - * @throws NoPreviousThrowableException - * @throws ProcessIsolationException - * @throws StaticAnalysisCacheNotConfiguredException - */ - public function runInSeparateProcess(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void - { - $class = new ReflectionClass($test); - - if ($runEntireClass) { - $template = new Template( - __DIR__ . '/../Util/PHP/Template/TestCaseClass.tpl', - ); - } else { - $template = new Template( - __DIR__ . '/../Util/PHP/Template/TestCaseMethod.tpl', - ); - } - - $bootstrap = ''; - $constants = ''; - $globals = ''; - $includedFiles = ''; - $iniSettings = ''; - - if (ConfigurationRegistry::get()->hasBootstrap()) { - $bootstrap = ConfigurationRegistry::get()->bootstrap(); - } - - if ($preserveGlobalState) { - $constants = GlobalState::getConstantsAsString(); - $globals = GlobalState::getGlobalsAsString(); - $includedFiles = GlobalState::getIncludedFilesAsString(); - $iniSettings = GlobalState::getIniSettingsAsString(); - } - - $coverage = CodeCoverage::instance()->isActive() ? 'true' : 'false'; - $linesToBeIgnored = var_export(CodeCoverage::instance()->linesToBeIgnored(), true); - - if (defined('PHPUNIT_COMPOSER_INSTALL')) { - $composerAutoload = var_export(PHPUNIT_COMPOSER_INSTALL, true); - } else { - $composerAutoload = '\'\''; - } - - if (defined('__PHPUNIT_PHAR__')) { - $phar = var_export(__PHPUNIT_PHAR__, true); - } else { - $phar = '\'\''; - } - - $data = var_export(serialize($test->providedData()), true); - $dataName = var_export($test->dataName(), true); - $dependencyInput = var_export(serialize($test->dependencyInput()), true); - $includePath = var_export(get_include_path(), true); - // must do these fixes because TestCaseMethod.tpl has unserialize('{data}') in it, and we can't break BC - // the lines above used to use addcslashes() rather than var_export(), which breaks null byte escape sequences - $data = "'." . $data . ".'"; - $dataName = "'.(" . $dataName . ").'"; - $dependencyInput = "'." . $dependencyInput . ".'"; - $includePath = "'." . $includePath . ".'"; - $offset = hrtime(); - $serializedConfiguration = $this->saveConfigurationForChildProcess(); - $processResultFile = tempnam(sys_get_temp_dir(), 'phpunit_'); - - $var = [ - 'bootstrap' => $bootstrap, - 'composerAutoload' => $composerAutoload, - 'phar' => $phar, - 'filename' => $class->getFileName(), - 'className' => $class->getName(), - 'collectCodeCoverageInformation' => $coverage, - 'linesToBeIgnored' => $linesToBeIgnored, - 'data' => $data, - 'dataName' => $dataName, - 'dependencyInput' => $dependencyInput, - 'constants' => $constants, - 'globals' => $globals, - 'include_path' => $includePath, - 'included_files' => $includedFiles, - 'iniSettings' => $iniSettings, - 'name' => $test->name(), - 'offsetSeconds' => $offset[0], - 'offsetNanoseconds' => $offset[1], - 'serializedConfiguration' => $serializedConfiguration, - 'processResultFile' => $processResultFile, - ]; - - if (!$runEntireClass) { - $var['methodName'] = $test->name(); - } - - $template->setVar($var); - - $code = $template->render(); - - assert($code !== ''); - - $this->runTestJob($code, $test, $processResultFile); - - @unlink($serializedConfiguration); - } - /** * @psalm-param class-string $className * @psalm-param non-empty-string $methodName @@ -454,24 +322,6 @@ private function runTestWithTimeout(TestCase $test): bool return false; } - /** - * @throws ProcessIsolationException - */ - private function saveConfigurationForChildProcess(): string - { - $path = tempnam(sys_get_temp_dir(), 'phpunit_'); - - if ($path === false) { - throw new ProcessIsolationException; - } - - if (!ConfigurationRegistry::saveTo($path)) { - throw new ProcessIsolationException; - } - - return $path; - } - private function shouldErrorHandlerBeUsed(TestCase $test): bool { if (MetadataRegistry::parser()->forMethod($test::class, $test->name())->isWithoutErrorHandler()->isNotEmpty()) { @@ -480,119 +330,4 @@ private function shouldErrorHandlerBeUsed(TestCase $test): bool return true; } - - /** - * @psalm-param non-empty-string $code - * - * @throws Exception - * @throws NoPreviousThrowableException - * @throws PhpProcessException - */ - private function runTestJob(string $code, Test $test, string $processResultFile): void - { - $result = JobRunnerRegistry::run(new Job($code)); - - $processResult = ''; - - if (file_exists($processResultFile)) { - $processResult = file_get_contents($processResultFile); - - @unlink($processResultFile); - } - - $this->processChildResult( - $test, - $processResult, - $result->stderr(), - ); - } - - /** - * @throws Exception - * @throws NoPreviousThrowableException - */ - private function processChildResult(Test $test, string $stdout, string $stderr): void - { - if (!empty($stderr)) { - $exception = new Exception(trim($stderr)); - - assert($test instanceof TestCase); - - Facade::emitter()->testErrored( - TestMethodBuilder::fromTestCase($test), - ThrowableBuilder::from($exception), - ); - - return; - } - - set_error_handler( - /** - * @throws ErrorException - */ - static function (int $errno, string $errstr, string $errfile, int $errline): never - { - throw new ErrorException($errstr, $errno, $errno, $errfile, $errline); - }, - ); - - try { - $childResult = unserialize($stdout); - - restore_error_handler(); - - if ($childResult === false) { - $exception = new AssertionFailedError('Test was run in child process and ended unexpectedly'); - - assert($test instanceof TestCase); - - Facade::emitter()->testErrored( - TestMethodBuilder::fromTestCase($test), - ThrowableBuilder::from($exception), - ); - - Facade::emitter()->testFinished( - TestMethodBuilder::fromTestCase($test), - 0, - ); - } - } catch (ErrorException $e) { - restore_error_handler(); - - $childResult = false; - - $exception = new Exception(trim($stdout), 0, $e); - - assert($test instanceof TestCase); - - Facade::emitter()->testErrored( - TestMethodBuilder::fromTestCase($test), - ThrowableBuilder::from($exception), - ); - } - - if ($childResult !== false) { - if (!empty($childResult['output'])) { - $output = $childResult['output']; - } - - Facade::instance()->forward($childResult['events']); - PassedTests::instance()->import($childResult['passedTests']); - - assert($test instanceof TestCase); - - $test->setResult($childResult['testResult']); - $test->addToAssertionCount($childResult['numAssertions']); - - if (CodeCoverage::instance()->isActive() && $childResult['codeCoverage'] instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { - CodeCoverage::instance()->codeCoverage()->merge( - $childResult['codeCoverage'], - ); - } - } - - if (!empty($output)) { - print $output; - } - } } diff --git a/src/Util/PHP/Template/TestCaseClass.tpl b/src/Framework/TestRunner/templates/class.tpl similarity index 100% rename from src/Util/PHP/Template/TestCaseClass.tpl rename to src/Framework/TestRunner/templates/class.tpl diff --git a/src/Util/PHP/Template/TestCaseMethod.tpl b/src/Framework/TestRunner/templates/method.tpl similarity index 100% rename from src/Util/PHP/Template/TestCaseMethod.tpl rename to src/Framework/TestRunner/templates/method.tpl diff --git a/src/Runner/PhptTestCase.php b/src/Runner/PHPT/PhptTestCase.php similarity index 99% rename from src/Runner/PhptTestCase.php rename to src/Runner/PHPT/PhptTestCase.php index 3a48ef6a847..7c4a958c707 100644 --- a/src/Runner/PhptTestCase.php +++ b/src/Runner/PHPT/PhptTestCase.php @@ -616,7 +616,7 @@ private function renderForCoverage(string &$job, bool $pathCoverage, ?string $co $files = $this->getCoverageFiles(); $template = new Template( - __DIR__ . '/../Util/PHP/Template/PhptTestCase.tpl', + __DIR__ . '/templates/phpt.tpl', ); $composerAutoload = '\'\''; diff --git a/src/Util/PHP/Template/PhptTestCase.tpl b/src/Runner/PHPT/templates/phpt.tpl similarity index 100% rename from src/Util/PHP/Template/PhptTestCase.tpl rename to src/Runner/PHPT/templates/phpt.tpl