|
| 1 | +<?php declare(strict_types=1); |
| 2 | +/* |
| 3 | + * This file is part of PHPUnit. |
| 4 | + * |
| 5 | + * (c) Sebastian Bergmann <[email protected]> |
| 6 | + * |
| 7 | + * For the full copyright and license information, please view the LICENSE |
| 8 | + * file that was distributed with this source code. |
| 9 | + */ |
| 10 | +namespace PHPUnit\Framework; |
| 11 | + |
| 12 | +use function assert; |
| 13 | +use function defined; |
| 14 | +use function file_exists; |
| 15 | +use function file_get_contents; |
| 16 | +use function get_include_path; |
| 17 | +use function hrtime; |
| 18 | +use function restore_error_handler; |
| 19 | +use function serialize; |
| 20 | +use function set_error_handler; |
| 21 | +use function sys_get_temp_dir; |
| 22 | +use function tempnam; |
| 23 | +use function trim; |
| 24 | +use function unlink; |
| 25 | +use function unserialize; |
| 26 | +use function var_export; |
| 27 | +use ErrorException; |
| 28 | +use PHPUnit\Event\Code\TestMethodBuilder; |
| 29 | +use PHPUnit\Event\Code\ThrowableBuilder; |
| 30 | +use PHPUnit\Event\Facade; |
| 31 | +use PHPUnit\Event\NoPreviousThrowableException; |
| 32 | +use PHPUnit\Runner\CodeCoverage; |
| 33 | +use PHPUnit\TestRunner\TestResult\PassedTests; |
| 34 | +use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; |
| 35 | +use PHPUnit\Util\GlobalState; |
| 36 | +use PHPUnit\Util\PHP\Job; |
| 37 | +use PHPUnit\Util\PHP\JobRunnerRegistry; |
| 38 | +use PHPUnit\Util\PHP\PhpProcessException; |
| 39 | +use ReflectionClass; |
| 40 | +use SebastianBergmann\CodeCoverage\StaticAnalysisCacheNotConfiguredException; |
| 41 | +use SebastianBergmann\Template\InvalidArgumentException; |
| 42 | +use SebastianBergmann\Template\Template; |
| 43 | + |
| 44 | +/** |
| 45 | + * @internal This interface is not covered by the backward compatibility promise for PHPUnit |
| 46 | + */ |
| 47 | +final class SeparateProcessTestRunner implements IsolatedTestRunner |
| 48 | +{ |
| 49 | + /** |
| 50 | + * @throws \PHPUnit\Runner\Exception |
| 51 | + * @throws \PHPUnit\Util\Exception |
| 52 | + * @throws Exception |
| 53 | + * @throws InvalidArgumentException |
| 54 | + * @throws NoPreviousThrowableException |
| 55 | + * @throws ProcessIsolationException |
| 56 | + * @throws StaticAnalysisCacheNotConfiguredException |
| 57 | + */ |
| 58 | + public function run(TestCase $test, bool $runEntireClass, bool $preserveGlobalState): void |
| 59 | + { |
| 60 | + $class = new ReflectionClass($test); |
| 61 | + |
| 62 | + if ($runEntireClass) { |
| 63 | + $template = new Template( |
| 64 | + __DIR__ . '/templates/class.tpl', |
| 65 | + ); |
| 66 | + } else { |
| 67 | + $template = new Template( |
| 68 | + __DIR__ . '/templates/method.tpl', |
| 69 | + ); |
| 70 | + } |
| 71 | + |
| 72 | + $bootstrap = ''; |
| 73 | + $constants = ''; |
| 74 | + $globals = ''; |
| 75 | + $includedFiles = ''; |
| 76 | + $iniSettings = ''; |
| 77 | + |
| 78 | + if (ConfigurationRegistry::get()->hasBootstrap()) { |
| 79 | + $bootstrap = ConfigurationRegistry::get()->bootstrap(); |
| 80 | + } |
| 81 | + |
| 82 | + if ($preserveGlobalState) { |
| 83 | + $constants = GlobalState::getConstantsAsString(); |
| 84 | + $globals = GlobalState::getGlobalsAsString(); |
| 85 | + $includedFiles = GlobalState::getIncludedFilesAsString(); |
| 86 | + $iniSettings = GlobalState::getIniSettingsAsString(); |
| 87 | + } |
| 88 | + |
| 89 | + $coverage = CodeCoverage::instance()->isActive() ? 'true' : 'false'; |
| 90 | + $linesToBeIgnored = var_export(CodeCoverage::instance()->linesToBeIgnored(), true); |
| 91 | + |
| 92 | + if (defined('PHPUNIT_COMPOSER_INSTALL')) { |
| 93 | + $composerAutoload = var_export(PHPUNIT_COMPOSER_INSTALL, true); |
| 94 | + } else { |
| 95 | + $composerAutoload = '\'\''; |
| 96 | + } |
| 97 | + |
| 98 | + if (defined('__PHPUNIT_PHAR__')) { |
| 99 | + $phar = var_export(__PHPUNIT_PHAR__, true); |
| 100 | + } else { |
| 101 | + $phar = '\'\''; |
| 102 | + } |
| 103 | + |
| 104 | + $data = var_export(serialize($test->providedData()), true); |
| 105 | + $dataName = var_export($test->dataName(), true); |
| 106 | + $dependencyInput = var_export(serialize($test->dependencyInput()), true); |
| 107 | + $includePath = var_export(get_include_path(), true); |
| 108 | + // must do these fixes because TestCaseMethod.tpl has unserialize('{data}') in it, and we can't break BC |
| 109 | + // the lines above used to use addcslashes() rather than var_export(), which breaks null byte escape sequences |
| 110 | + $data = "'." . $data . ".'"; |
| 111 | + $dataName = "'.(" . $dataName . ").'"; |
| 112 | + $dependencyInput = "'." . $dependencyInput . ".'"; |
| 113 | + $includePath = "'." . $includePath . ".'"; |
| 114 | + $offset = hrtime(); |
| 115 | + $serializedConfiguration = $this->saveConfigurationForChildProcess(); |
| 116 | + $processResultFile = tempnam(sys_get_temp_dir(), 'phpunit_'); |
| 117 | + |
| 118 | + $var = [ |
| 119 | + 'bootstrap' => $bootstrap, |
| 120 | + 'composerAutoload' => $composerAutoload, |
| 121 | + 'phar' => $phar, |
| 122 | + 'filename' => $class->getFileName(), |
| 123 | + 'className' => $class->getName(), |
| 124 | + 'collectCodeCoverageInformation' => $coverage, |
| 125 | + 'linesToBeIgnored' => $linesToBeIgnored, |
| 126 | + 'data' => $data, |
| 127 | + 'dataName' => $dataName, |
| 128 | + 'dependencyInput' => $dependencyInput, |
| 129 | + 'constants' => $constants, |
| 130 | + 'globals' => $globals, |
| 131 | + 'include_path' => $includePath, |
| 132 | + 'included_files' => $includedFiles, |
| 133 | + 'iniSettings' => $iniSettings, |
| 134 | + 'name' => $test->name(), |
| 135 | + 'offsetSeconds' => $offset[0], |
| 136 | + 'offsetNanoseconds' => $offset[1], |
| 137 | + 'serializedConfiguration' => $serializedConfiguration, |
| 138 | + 'processResultFile' => $processResultFile, |
| 139 | + ]; |
| 140 | + |
| 141 | + if (!$runEntireClass) { |
| 142 | + $var['methodName'] = $test->name(); |
| 143 | + } |
| 144 | + |
| 145 | + $template->setVar($var); |
| 146 | + |
| 147 | + $code = $template->render(); |
| 148 | + |
| 149 | + assert($code !== ''); |
| 150 | + |
| 151 | + $this->runTestJob($code, $test, $processResultFile); |
| 152 | + |
| 153 | + @unlink($serializedConfiguration); |
| 154 | + } |
| 155 | + |
| 156 | + /** |
| 157 | + * @psalm-param non-empty-string $code |
| 158 | + * |
| 159 | + * @throws Exception |
| 160 | + * @throws NoPreviousThrowableException |
| 161 | + * @throws PhpProcessException |
| 162 | + */ |
| 163 | + private function runTestJob(string $code, Test $test, string $processResultFile): void |
| 164 | + { |
| 165 | + $result = JobRunnerRegistry::run(new Job($code)); |
| 166 | + |
| 167 | + $processResult = ''; |
| 168 | + |
| 169 | + if (file_exists($processResultFile)) { |
| 170 | + $processResult = file_get_contents($processResultFile); |
| 171 | + |
| 172 | + @unlink($processResultFile); |
| 173 | + } |
| 174 | + |
| 175 | + $this->processChildResult( |
| 176 | + $test, |
| 177 | + $processResult, |
| 178 | + $result->stderr(), |
| 179 | + ); |
| 180 | + } |
| 181 | + |
| 182 | + /** |
| 183 | + * @throws Exception |
| 184 | + * @throws NoPreviousThrowableException |
| 185 | + */ |
| 186 | + private function processChildResult(Test $test, string $stdout, string $stderr): void |
| 187 | + { |
| 188 | + if (!empty($stderr)) { |
| 189 | + $exception = new Exception(trim($stderr)); |
| 190 | + |
| 191 | + assert($test instanceof TestCase); |
| 192 | + |
| 193 | + Facade::emitter()->testErrored( |
| 194 | + TestMethodBuilder::fromTestCase($test), |
| 195 | + ThrowableBuilder::from($exception), |
| 196 | + ); |
| 197 | + |
| 198 | + return; |
| 199 | + } |
| 200 | + |
| 201 | + set_error_handler( |
| 202 | + /** |
| 203 | + * @throws ErrorException |
| 204 | + */ |
| 205 | + static function (int $errno, string $errstr, string $errfile, int $errline): never |
| 206 | + { |
| 207 | + throw new ErrorException($errstr, $errno, $errno, $errfile, $errline); |
| 208 | + }, |
| 209 | + ); |
| 210 | + |
| 211 | + try { |
| 212 | + $childResult = unserialize($stdout); |
| 213 | + |
| 214 | + restore_error_handler(); |
| 215 | + |
| 216 | + if ($childResult === false) { |
| 217 | + $exception = new AssertionFailedError('Test was run in child process and ended unexpectedly'); |
| 218 | + |
| 219 | + assert($test instanceof TestCase); |
| 220 | + |
| 221 | + Facade::emitter()->testErrored( |
| 222 | + TestMethodBuilder::fromTestCase($test), |
| 223 | + ThrowableBuilder::from($exception), |
| 224 | + ); |
| 225 | + |
| 226 | + Facade::emitter()->testFinished( |
| 227 | + TestMethodBuilder::fromTestCase($test), |
| 228 | + 0, |
| 229 | + ); |
| 230 | + } |
| 231 | + } catch (ErrorException $e) { |
| 232 | + restore_error_handler(); |
| 233 | + |
| 234 | + $childResult = false; |
| 235 | + |
| 236 | + $exception = new Exception(trim($stdout), 0, $e); |
| 237 | + |
| 238 | + assert($test instanceof TestCase); |
| 239 | + |
| 240 | + Facade::emitter()->testErrored( |
| 241 | + TestMethodBuilder::fromTestCase($test), |
| 242 | + ThrowableBuilder::from($exception), |
| 243 | + ); |
| 244 | + } |
| 245 | + |
| 246 | + if ($childResult !== false) { |
| 247 | + if (!empty($childResult['output'])) { |
| 248 | + $output = $childResult['output']; |
| 249 | + } |
| 250 | + |
| 251 | + Facade::instance()->forward($childResult['events']); |
| 252 | + PassedTests::instance()->import($childResult['passedTests']); |
| 253 | + |
| 254 | + assert($test instanceof TestCase); |
| 255 | + |
| 256 | + $test->setResult($childResult['testResult']); |
| 257 | + $test->addToAssertionCount($childResult['numAssertions']); |
| 258 | + |
| 259 | + if (CodeCoverage::instance()->isActive() && $childResult['codeCoverage'] instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { |
| 260 | + CodeCoverage::instance()->codeCoverage()->merge( |
| 261 | + $childResult['codeCoverage'], |
| 262 | + ); |
| 263 | + } |
| 264 | + } |
| 265 | + |
| 266 | + if (!empty($output)) { |
| 267 | + print $output; |
| 268 | + } |
| 269 | + } |
| 270 | + |
| 271 | + /** |
| 272 | + * @throws ProcessIsolationException |
| 273 | + */ |
| 274 | + private function saveConfigurationForChildProcess(): string |
| 275 | + { |
| 276 | + $path = tempnam(sys_get_temp_dir(), 'phpunit_'); |
| 277 | + |
| 278 | + if ($path === false) { |
| 279 | + throw new ProcessIsolationException; |
| 280 | + } |
| 281 | + |
| 282 | + if (!ConfigurationRegistry::saveTo($path)) { |
| 283 | + throw new ProcessIsolationException; |
| 284 | + } |
| 285 | + |
| 286 | + return $path; |
| 287 | + } |
| 288 | +} |
0 commit comments