Blame | Letzte Änderung | Log anzeigen | RSS feed
<?php declare(strict_types=1);/** This file is part of PHPUnit.** (c) Sebastian Bergmann <sebastian@phpunit.de>** For the full copyright and license information, please view the LICENSE* file that was distributed with this source code.*/namespace PHPUnit\Runner;use const DEBUG_BACKTRACE_IGNORE_ARGS;use const DIRECTORY_SEPARATOR;use function array_merge;use function basename;use function debug_backtrace;use function defined;use function dirname;use function explode;use function extension_loaded;use function file;use function file_get_contents;use function file_put_contents;use function is_array;use function is_file;use function is_readable;use function is_string;use function ltrim;use function phpversion;use function preg_match;use function preg_replace;use function preg_split;use function realpath;use function rtrim;use function sprintf;use function str_replace;use function strncasecmp;use function strpos;use function substr;use function trim;use function unlink;use function unserialize;use function var_export;use function version_compare;use PHPUnit\Framework\Assert;use PHPUnit\Framework\AssertionFailedError;use PHPUnit\Framework\ExecutionOrderDependency;use PHPUnit\Framework\ExpectationFailedException;use PHPUnit\Framework\IncompleteTestError;use PHPUnit\Framework\PHPTAssertionFailedError;use PHPUnit\Framework\Reorderable;use PHPUnit\Framework\SelfDescribing;use PHPUnit\Framework\SkippedTestError;use PHPUnit\Framework\SyntheticSkippedError;use PHPUnit\Framework\Test;use PHPUnit\Framework\TestResult;use PHPUnit\Util\PHP\AbstractPhpProcess;use SebastianBergmann\CodeCoverage\RawCodeCoverageData;use SebastianBergmann\Template\Template;use SebastianBergmann\Timer\Timer;use Throwable;/*** @internal This class is not covered by the backward compatibility promise for PHPUnit*/final class PhptTestCase implements Reorderable, SelfDescribing, Test{/*** @var string*/private $filename;/*** @var AbstractPhpProcess*/private $phpUtil;/*** @var string*/private $output = '';/*** Constructs a test case with the given filename.** @throws Exception*/public function __construct(string $filename, AbstractPhpProcess $phpUtil = null){if (!is_file($filename)) {throw new Exception(sprintf('File "%s" does not exist.',$filename));}$this->filename = $filename;$this->phpUtil = $phpUtil ?: AbstractPhpProcess::factory();}/*** Counts the number of test cases executed by run(TestResult result).*/public function count(): int{return 1;}/*** Runs a test and collects its result in a TestResult instance.** @throws \SebastianBergmann\CodeCoverage\InvalidArgumentException* @throws \SebastianBergmann\CodeCoverage\UnintentionallyCoveredCodeException* @throws \SebastianBergmann\RecursionContext\InvalidArgumentException* @throws Exception*/public function run(TestResult $result = null): TestResult{if ($result === null) {$result = new TestResult;}try {$sections = $this->parse();} catch (Exception $e) {$result->startTest($this);$result->addFailure($this, new SkippedTestError($e->getMessage()), 0);$result->endTest($this, 0);return $result;}$code = $this->render($sections['FILE']);$xfail = false;$settings = $this->parseIniSection($this->settings($result->getCollectCodeCoverageInformation()));$result->startTest($this);if (isset($sections['INI'])) {$settings = $this->parseIniSection($sections['INI'], $settings);}if (isset($sections['ENV'])) {$env = $this->parseEnvSection($sections['ENV']);$this->phpUtil->setEnv($env);}$this->phpUtil->setUseStderrRedirection(true);if ($result->enforcesTimeLimit()) {$this->phpUtil->setTimeout($result->getTimeoutForLargeTests());}$skip = $this->runSkip($sections, $result, $settings);if ($skip) {return $result;}if (isset($sections['XFAIL'])) {$xfail = trim($sections['XFAIL']);}if (isset($sections['STDIN'])) {$this->phpUtil->setStdin($sections['STDIN']);}if (isset($sections['ARGS'])) {$this->phpUtil->setArgs($sections['ARGS']);}if ($result->getCollectCodeCoverageInformation()) {$codeCoverageCacheDirectory = null;$pathCoverage = false;$codeCoverage = $result->getCodeCoverage();if ($codeCoverage) {if ($codeCoverage->cachesStaticAnalysis()) {$codeCoverageCacheDirectory = $codeCoverage->cacheDirectory();}$pathCoverage = $codeCoverage->collectsBranchAndPathCoverage();}$this->renderForCoverage($code, $pathCoverage, $codeCoverageCacheDirectory);}$timer = new Timer;$timer->start();$jobResult = $this->phpUtil->runJob($code, $this->stringifyIni($settings));$time = $timer->stop()->asSeconds();$this->output = $jobResult['stdout'] ?? '';if (isset($codeCoverage) && ($coverage = $this->cleanupForCoverage())) {$codeCoverage->append($coverage, $this, true, [], []);}try {$this->assertPhptExpectation($sections, $this->output);} catch (AssertionFailedError $e) {$failure = $e;if ($xfail !== false) {$failure = new IncompleteTestError($xfail, 0, $e);} elseif ($e instanceof ExpectationFailedException) {$comparisonFailure = $e->getComparisonFailure();if ($comparisonFailure) {$diff = $comparisonFailure->getDiff();} else {$diff = $e->getMessage();}$hint = $this->getLocationHintFromDiff($diff, $sections);$trace = array_merge($hint, debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS));$failure = new PHPTAssertionFailedError($e->getMessage(),0,$trace[0]['file'],$trace[0]['line'],$trace,$comparisonFailure ? $diff : '');}$result->addFailure($this, $failure, $time);} catch (Throwable $t) {$result->addError($this, $t, $time);}if ($xfail !== false && $result->allCompletelyImplemented()) {$result->addFailure($this, new IncompleteTestError('XFAIL section but test passes'), $time);}$this->runClean($sections, $result->getCollectCodeCoverageInformation());$result->endTest($this, $time);return $result;}/*** Returns the name of the test case.*/public function getName(): string{return $this->toString();}/*** Returns a string representation of the test case.*/public function toString(): string{return $this->filename;}public function usesDataProvider(): bool{return false;}public function getNumAssertions(): int{return 1;}public function getActualOutput(): string{return $this->output;}public function hasOutput(): bool{return !empty($this->output);}public function sortId(): string{return $this->filename;}/*** @return list<ExecutionOrderDependency>*/public function provides(): array{return [];}/*** @return list<ExecutionOrderDependency>*/public function requires(): array{return [];}/*** Parse --INI-- section key value pairs and return as array.** @param array|string $content*/private function parseIniSection($content, array $ini = []): array{if (is_string($content)) {$content = explode("\n", trim($content));}foreach ($content as $setting) {if (strpos($setting, '=') === false) {continue;}$setting = explode('=', $setting, 2);$name = trim($setting[0]);$value = trim($setting[1]);if ($name === 'extension' || $name === 'zend_extension') {if (!isset($ini[$name])) {$ini[$name] = [];}$ini[$name][] = $value;continue;}$ini[$name] = $value;}return $ini;}private function parseEnvSection(string $content): array{$env = [];foreach (explode("\n", trim($content)) as $e) {$e = explode('=', trim($e), 2);if (!empty($e[0]) && isset($e[1])) {$env[$e[0]] = $e[1];}}return $env;}/*** @throws \SebastianBergmann\RecursionContext\InvalidArgumentException* @throws Exception* @throws ExpectationFailedException*/private function assertPhptExpectation(array $sections, string $output): void{$assertions = ['EXPECT' => 'assertEquals','EXPECTF' => 'assertStringMatchesFormat','EXPECTREGEX' => 'assertMatchesRegularExpression',];$actual = preg_replace('/\r\n/', "\n", trim($output));foreach ($assertions as $sectionName => $sectionAssertion) {if (isset($sections[$sectionName])) {$sectionContent = preg_replace('/\r\n/', "\n", trim($sections[$sectionName]));$expected = $sectionName === 'EXPECTREGEX' ? "/{$sectionContent}/" : $sectionContent;if ($expected === '') {throw new Exception('No PHPT expectation found');}Assert::$sectionAssertion($expected, $actual);return;}}throw new Exception('No PHPT assertion found');}/*** @throws \SebastianBergmann\RecursionContext\InvalidArgumentException*/private function runSkip(array &$sections, TestResult $result, array $settings): bool{if (!isset($sections['SKIPIF'])) {return false;}$skipif = $this->render($sections['SKIPIF']);$jobResult = $this->phpUtil->runJob($skipif, $this->stringifyIni($settings));if (!strncasecmp('skip', ltrim($jobResult['stdout']), 4)) {$message = '';if (preg_match('/^\s*skip\s*(.+)\s*/i', $jobResult['stdout'], $skipMatch)) {$message = substr($skipMatch[1], 2);}$hint = $this->getLocationHint($message, $sections, 'SKIPIF');$trace = array_merge($hint, debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS));$result->addFailure($this,new SyntheticSkippedError($message, 0, $trace[0]['file'], $trace[0]['line'], $trace),0);$result->endTest($this, 0);return true;}return false;}private function runClean(array &$sections, bool $collectCoverage): void{$this->phpUtil->setStdin('');$this->phpUtil->setArgs('');if (isset($sections['CLEAN'])) {$cleanCode = $this->render($sections['CLEAN']);$this->phpUtil->runJob($cleanCode, $this->settings($collectCoverage));}}/*** @throws Exception*/private function parse(): array{$sections = [];$section = '';$unsupportedSections = ['CGI','COOKIE','DEFLATE_POST','EXPECTHEADERS','EXTENSIONS','GET','GZIP_POST','HEADERS','PHPDBG','POST','POST_RAW','PUT','REDIRECTTEST','REQUEST',];$lineNr = 0;foreach (file($this->filename) as $line) {$lineNr++;if (preg_match('/^--([_A-Z]+)--/', $line, $result)) {$section = $result[1];$sections[$section] = '';$sections[$section . '_offset'] = $lineNr;continue;}if (empty($section)) {throw new Exception('Invalid PHPT file: empty section header');}$sections[$section] .= $line;}if (isset($sections['FILEEOF'])) {$sections['FILE'] = rtrim($sections['FILEEOF'], "\r\n");unset($sections['FILEEOF']);}$this->parseExternal($sections);if (!$this->validate($sections)) {throw new Exception('Invalid PHPT file');}foreach ($unsupportedSections as $section) {if (isset($sections[$section])) {throw new Exception("PHPUnit does not support PHPT {$section} sections");}}return $sections;}/*** @throws Exception*/private function parseExternal(array &$sections): void{$allowSections = ['FILE','EXPECT','EXPECTF','EXPECTREGEX',];$testDirectory = dirname($this->filename) . DIRECTORY_SEPARATOR;foreach ($allowSections as $section) {if (isset($sections[$section . '_EXTERNAL'])) {$externalFilename = trim($sections[$section . '_EXTERNAL']);if (!is_file($testDirectory . $externalFilename) ||!is_readable($testDirectory . $externalFilename)) {throw new Exception(sprintf('Could not load --%s-- %s for PHPT file',$section . '_EXTERNAL',$testDirectory . $externalFilename));}$sections[$section] = file_get_contents($testDirectory . $externalFilename);}}}private function validate(array &$sections): bool{$requiredSections = ['FILE',['EXPECT','EXPECTF','EXPECTREGEX',],];foreach ($requiredSections as $section) {if (is_array($section)) {$foundSection = false;foreach ($section as $anySection) {if (isset($sections[$anySection])) {$foundSection = true;break;}}if (!$foundSection) {return false;}continue;}if (!isset($sections[$section])) {return false;}}return true;}private function render(string $code): string{return str_replace(['__DIR__','__FILE__',],["'" . dirname($this->filename) . "'","'" . $this->filename . "'",],$code);}private function getCoverageFiles(): array{$baseDir = dirname(realpath($this->filename)) . DIRECTORY_SEPARATOR;$basename = basename($this->filename, 'phpt');return ['coverage' => $baseDir . $basename . 'coverage','job' => $baseDir . $basename . 'php',];}private function renderForCoverage(string &$job, bool $pathCoverage, ?string $codeCoverageCacheDirectory): void{$files = $this->getCoverageFiles();$template = new Template(__DIR__ . '/../Util/PHP/Template/PhptTestCase.tpl');$composerAutoload = '\'\'';if (defined('PHPUNIT_COMPOSER_INSTALL')) {$composerAutoload = var_export(PHPUNIT_COMPOSER_INSTALL, true);}$phar = '\'\'';if (defined('__PHPUNIT_PHAR__')) {$phar = var_export(__PHPUNIT_PHAR__, true);}$globals = '';if (!empty($GLOBALS['__PHPUNIT_BOOTSTRAP'])) {$globals = '$GLOBALS[\'__PHPUNIT_BOOTSTRAP\'] = ' . var_export($GLOBALS['__PHPUNIT_BOOTSTRAP'],true) . ";\n";}if ($codeCoverageCacheDirectory === null) {$codeCoverageCacheDirectory = 'null';} else {$codeCoverageCacheDirectory = "'" . $codeCoverageCacheDirectory . "'";}$template->setVar(['composerAutoload' => $composerAutoload,'phar' => $phar,'globals' => $globals,'job' => $files['job'],'coverageFile' => $files['coverage'],'driverMethod' => $pathCoverage ? 'forLineAndPathCoverage' : 'forLineCoverage','codeCoverageCacheDirectory' => $codeCoverageCacheDirectory,]);file_put_contents($files['job'], $job);$job = $template->render();}private function cleanupForCoverage(): RawCodeCoverageData{$coverage = RawCodeCoverageData::fromXdebugWithoutPathCoverage([]);$files = $this->getCoverageFiles();if (is_file($files['coverage'])) {$buffer = @file_get_contents($files['coverage']);if ($buffer !== false) {$coverage = @unserialize($buffer);if ($coverage === false) {$coverage = RawCodeCoverageData::fromXdebugWithoutPathCoverage([]);}}}foreach ($files as $file) {@unlink($file);}return $coverage;}private function stringifyIni(array $ini): array{$settings = [];foreach ($ini as $key => $value) {if (is_array($value)) {foreach ($value as $val) {$settings[] = $key . '=' . $val;}continue;}$settings[] = $key . '=' . $value;}return $settings;}private function getLocationHintFromDiff(string $message, array $sections): array{$needle = '';$previousLine = '';$block = 'message';foreach (preg_split('/\r\n|\r|\n/', $message) as $line) {$line = trim($line);if ($block === 'message' && $line === '--- Expected') {$block = 'expected';}if ($block === 'expected' && $line === '@@ @@') {$block = 'diff';}if ($block === 'diff') {if (strpos($line, '+') === 0) {$needle = $this->getCleanDiffLine($previousLine);break;}if (strpos($line, '-') === 0) {$needle = $this->getCleanDiffLine($line);break;}}if (!empty($line)) {$previousLine = $line;}}return $this->getLocationHint($needle, $sections);}private function getCleanDiffLine(string $line): string{if (preg_match('/^[\-+]([\'\"]?)(.*)\1$/', $line, $matches)) {$line = $matches[2];}return $line;}private function getLocationHint(string $needle, array $sections, ?string $sectionName = null): array{$needle = trim($needle);if (empty($needle)) {return [['file' => realpath($this->filename),'line' => 1,]];}if ($sectionName) {$search = [$sectionName];} else {$search = [// 'FILE','EXPECT','EXPECTF','EXPECTREGEX',];}$sectionOffset = null;foreach ($search as $section) {if (!isset($sections[$section])) {continue;}if (isset($sections[$section . '_EXTERNAL'])) {$externalFile = trim($sections[$section . '_EXTERNAL']);return [['file' => realpath(dirname($this->filename) . DIRECTORY_SEPARATOR . $externalFile),'line' => 1,],['file' => realpath($this->filename),'line' => ($sections[$section . '_EXTERNAL_offset'] ?? 0) + 1,],];}$sectionOffset = $sections[$section . '_offset'] ?? 0;$offset = $sectionOffset + 1;foreach (preg_split('/\r\n|\r|\n/', $sections[$section]) as $line) {if (strpos($line, $needle) !== false) {return [['file' => realpath($this->filename),'line' => $offset,]];}$offset++;}}if ($sectionName) {// String not found in specified section, show user the start of the named sectionreturn [['file' => realpath($this->filename),'line' => $sectionOffset,]];}// No section specified, show user start of codereturn [['file' => realpath($this->filename),'line' => 1,]];}/*** @psalm-return list<string>*/private function settings(bool $collectCoverage): array{$settings = ['allow_url_fopen=1','auto_append_file=','auto_prepend_file=','disable_functions=','display_errors=1','docref_ext=.html','docref_root=','error_append_string=','error_prepend_string=','error_reporting=-1','html_errors=0','log_errors=0','open_basedir=','output_buffering=Off','output_handler=','report_memleaks=0','report_zend_debug=0',];if (extension_loaded('pcov')) {if ($collectCoverage) {$settings[] = 'pcov.enabled=1';} else {$settings[] = 'pcov.enabled=0';}}if (extension_loaded('xdebug')) {if (version_compare(phpversion('xdebug'), '3', '>=')) {if ($collectCoverage) {$settings[] = 'xdebug.mode=coverage';} else {$settings[] = 'xdebug.mode=off';}} else {$settings[] = 'xdebug.default_enable=0';if ($collectCoverage) {$settings[] = 'xdebug.coverage_enable=1';}}}return $settings;}}