├── src ├── tester ├── Runner │ ├── exceptions.php │ ├── OutputHandler.php │ ├── Output │ │ ├── TapPrinter.php │ │ ├── JUnitPrinter.php │ │ ├── Logger.php │ │ └── ConsolePrinter.php │ ├── info.php │ ├── Test.php │ ├── PhpInterpreter.php │ ├── Job.php │ ├── CommandLine.php │ ├── Runner.php │ ├── TestHandler.php │ └── CliTester.php ├── Framework │ ├── AssertException.php │ ├── functions.php │ ├── DataProvider.php │ ├── Expect.php │ ├── Helpers.php │ ├── FileMock.php │ ├── DomQuery.php │ ├── FileMutator.php │ ├── HttpAssert.php │ ├── Environment.php │ ├── TestCase.php │ ├── Dumper.php │ └── Assert.php ├── bootstrap.php ├── tester.php └── CodeCoverage │ ├── Generators │ ├── HtmlGenerator.php │ ├── AbstractGenerator.php │ ├── CloverXMLGenerator.php │ └── template.phtml │ ├── Collector.php │ └── PhpParser.php ├── composer.json ├── license.md └── readme.md /src/tester: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | expected = $expected; 28 | $this->actual = $actual; 29 | $this->setMessage($message); 30 | } 31 | 32 | 33 | public function setMessage(string $message): self 34 | { 35 | $this->origMessage = $message; 36 | $this->message = strtr($message, [ 37 | '%1' => Dumper::toLine($this->actual), 38 | '%2' => Dumper::toLine($this->expected), 39 | ]); 40 | return $this; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nette/tester", 3 | "description": "Nette Tester: enjoyable unit testing in PHP with code coverage reporter. 🍏🍏🍎🍏", 4 | "homepage": "https://tester.nette.org", 5 | "keywords": ["testing", "unit", "nette", "phpunit", "code coverage", "xdebug", "phpdbg", "pcov", "clover", "assertions"], 6 | "license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"], 7 | "authors": [ 8 | { 9 | "name": "David Grudl", 10 | "homepage": "https://davidgrudl.com" 11 | }, 12 | { 13 | "name": "Miloslav Hůla", 14 | "homepage": "https://github.com/milo" 15 | }, 16 | { 17 | "name": "Nette Community", 18 | "homepage": "https://nette.org/contributors" 19 | } 20 | ], 21 | "require": { 22 | "php": "8.0 - 8.5" 23 | }, 24 | "require-dev": { 25 | "ext-simplexml": "*", 26 | "phpstan/phpstan-nette": "^2.0@stable" 27 | }, 28 | "autoload": { 29 | "classmap": ["src/"], 30 | "psr-4": { 31 | "Tester\\": "src" 32 | } 33 | }, 34 | "bin": ["src/tester"], 35 | "scripts": { 36 | "phpstan": "phpstan analyse", 37 | "tester": "tester tests -s" 38 | }, 39 | "extra": { 40 | "branch-alias": { "dev-master": "2.5-dev" } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/bootstrap.php: -------------------------------------------------------------------------------- 1 | run()); 38 | -------------------------------------------------------------------------------- /src/Runner/Output/TapPrinter.php: -------------------------------------------------------------------------------- 1 | file = fopen($file ?: 'php://output', 'w'); 29 | } 30 | 31 | 32 | public function begin(): void 33 | { 34 | $this->results = [ 35 | Test::Passed => 0, 36 | Test::Skipped => 0, 37 | Test::Failed => 0, 38 | ]; 39 | fwrite($this->file, "TAP version 13\n"); 40 | } 41 | 42 | 43 | public function prepare(Test $test): void 44 | { 45 | } 46 | 47 | 48 | public function finish(Test $test): void 49 | { 50 | $this->results[$test->getResult()]++; 51 | $message = str_replace("\n", "\n# ", trim((string) $test->message)); 52 | $outputs = [ 53 | Test::Passed => "ok {$test->getSignature()}", 54 | Test::Skipped => "ok {$test->getSignature()} #skip $message", 55 | Test::Failed => "not ok {$test->getSignature()}\n# $message", 56 | ]; 57 | fwrite($this->file, $outputs[$test->getResult()] . "\n"); 58 | } 59 | 60 | 61 | public function end(): void 62 | { 63 | fwrite($this->file, '1..' . array_sum($this->results)); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Runner/info.php: -------------------------------------------------------------------------------- 1 | defined('PHP_BINARY') ? PHP_BINARY : null, 17 | 'version' => PHP_VERSION, 18 | 'phpDbgVersion' => $isPhpDbg ? PHPDBG_VERSION : null, 19 | 'sapi' => PHP_SAPI, 20 | 'iniFiles' => array_merge( 21 | ($tmp = php_ini_loaded_file()) === false ? [] : [$tmp], 22 | (function_exists('php_ini_scanned_files') && strlen($tmp = (string) php_ini_scanned_files())) ? explode(",\n", trim($tmp)) : [], 23 | ), 24 | 'extensions' => $extensions, 25 | 'tempDir' => sys_get_temp_dir(), 26 | 'codeCoverageEngines' => Tester\CodeCoverage\Collector::detectEngines(), 27 | ]; 28 | 29 | if (isset($_SERVER['argv'][1])) { 30 | echo serialize($info); 31 | die; 32 | } 33 | 34 | foreach ([ 35 | 'PHP binary' => $info->binary ?: '(not available)', 36 | 'PHP version' . ($isPhpDbg ? '; PHPDBG version' : '') 37 | => "$info->version ($info->sapi)" . ($isPhpDbg ? "; $info->phpDbgVersion" : ''), 38 | 'Loaded php.ini files' => count($info->iniFiles) ? implode(', ', $info->iniFiles) : '(none)', 39 | 'Code coverage engines' => count($info->codeCoverageEngines) 40 | ? implode(', ', array_map(fn(array $engineInfo) => vsprintf('%s (%s)', $engineInfo), $info->codeCoverageEngines)) 41 | : '(not available)', 42 | 'PHP temporary directory' => $info->tempDir == '' ? '(empty)' : $info->tempDir, 43 | 'Loaded extensions' => count($info->extensions) ? implode(', ', $info->extensions) : '(none)', 44 | ] as $title => $value) { 45 | echo "\e[1;32m$title\e[0m:\n$value\n\n"; 46 | } 47 | -------------------------------------------------------------------------------- /src/Framework/functions.php: -------------------------------------------------------------------------------- 1 | 2) { 16 | throw new Exception(__FUNCTION__ . "() expects 2 parameters, $count given."); 17 | } 18 | 19 | if ($fn = (new ReflectionFunction('setUp'))->getStaticVariables()['fn']) { 20 | $fn(); 21 | } 22 | 23 | try { 24 | $closure(); 25 | if ($description !== '') { 26 | Environment::print(Dumper::color('lime', '√') . " $description"); 27 | } 28 | 29 | } catch (Throwable $e) { 30 | if ($description !== '') { 31 | Environment::print(Dumper::color('red', '×') . " $description\n\n"); 32 | } 33 | throw $e; 34 | } 35 | 36 | if ($fn = (new ReflectionFunction('tearDown'))->getStaticVariables()['fn']) { 37 | $fn(); 38 | } 39 | } 40 | 41 | 42 | /** 43 | * Tests for exceptions thrown by a provided closure matching specific criteria. 44 | */ 45 | function testException( 46 | string $description, 47 | Closure $function, 48 | string $class, 49 | ?string $message = null, 50 | $code = null, 51 | ): void 52 | { 53 | try { 54 | Assert::exception($function, $class, $message, $code); 55 | if ($description !== '') { 56 | Environment::print(Dumper::color('lime', '√') . " $description"); 57 | } 58 | 59 | } catch (Throwable $e) { 60 | if ($description !== '') { 61 | Environment::print(Dumper::color('red', '×') . " $description\n\n"); 62 | } 63 | throw $e; 64 | } 65 | } 66 | 67 | 68 | /** 69 | * Registers a function to be called before each test execution. 70 | */ 71 | function setUp(?Closure $closure): void 72 | { 73 | static $fn; 74 | $fn = $closure; 75 | } 76 | 77 | 78 | /** 79 | * Registers a function to be called after each test execution. 80 | */ 81 | function tearDown(?Closure $closure): void 82 | { 83 | static $fn; 84 | $fn = $closure; 85 | } 86 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Licenses 2 | ======== 3 | 4 | Good news! You may use Nette Tester under the terms of either 5 | the New BSD License or the GNU General Public License (GPL) version 2 or 3. 6 | 7 | 8 | 9 | New BSD License 10 | --------------- 11 | 12 | Copyright (c) 2004, 2013 David Grudl (https://davidgrudl.com) 13 | All rights reserved. 14 | 15 | Redistribution and use in source and binary forms, with or without modification, 16 | are permitted provided that the following conditions are met: 17 | 18 | * Redistributions of source code must retain the above copyright notice, 19 | this list of conditions and the following disclaimer. 20 | 21 | * Redistributions in binary form must reproduce the above copyright notice, 22 | this list of conditions and the following disclaimer in the documentation 23 | and/or other materials provided with the distribution. 24 | 25 | * Neither the name of "Nette Tester" nor the names of its contributors 26 | may be used to endorse or promote products derived from this software 27 | without specific prior written permission. 28 | 29 | This software is provided by the copyright holders and contributors "as is" and 30 | any express or implied warranties, including, but not limited to, the implied 31 | warranties of merchantability and fitness for a particular purpose are 32 | disclaimed. In no event shall the copyright owner or contributors be liable for 33 | any direct, indirect, incidental, special, exemplary, or consequential damages 34 | (including, but not limited to, procurement of substitute goods or services; 35 | loss of use, data, or profits; or business interruption) however caused and on 36 | any theory of liability, whether in contract, strict liability, or tort 37 | (including negligence or otherwise) arising in any way out of the use of this 38 | software, even if advised of the possibility of such damage. 39 | 40 | 41 | 42 | GNU General Public License 43 | -------------------------- 44 | 45 | GPL licenses are very very long, so instead of including them here we offer 46 | you URLs with full text: 47 | 48 | - [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html) 49 | - [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html) 50 | -------------------------------------------------------------------------------- /src/Runner/Output/JUnitPrinter.php: -------------------------------------------------------------------------------- 1 | file = fopen($file ?: 'php://output', 'w'); 33 | } 34 | 35 | 36 | public function begin(): void 37 | { 38 | $this->buffer = ''; 39 | $this->results = [ 40 | Test::Passed => 0, 41 | Test::Skipped => 0, 42 | Test::Failed => 0, 43 | ]; 44 | $this->startTime = microtime(true); 45 | fwrite($this->file, "\n\n"); 46 | } 47 | 48 | 49 | public function prepare(Test $test): void 50 | { 51 | } 52 | 53 | 54 | public function finish(Test $test): void 55 | { 56 | $this->results[$test->getResult()]++; 57 | $this->buffer .= "\t\tgetSignature()) . '" name="' . htmlspecialchars($test->getSignature()) . '"'; 58 | $this->buffer .= match ($test->getResult()) { 59 | Test::Failed => ">\n\t\t\tmessage, ENT_COMPAT | ENT_HTML5) . "\"/>\n\t\t\n", 60 | Test::Skipped => ">\n\t\t\t\n\t\t\n", 61 | Test::Passed => "/>\n", 62 | }; 63 | } 64 | 65 | 66 | public function end(): void 67 | { 68 | $time = sprintf('%0.1f', microtime(true) - $this->startTime); 69 | $output = $this->buffer; 70 | $this->buffer = "\tresults[Test::Failed]}\" skipped=\"{$this->results[Test::Skipped]}\" tests=\"" . array_sum($this->results) . "\" time=\"$time\" timestamp=\"" . @date('Y-m-d\TH:i:s') . "\">\n"; 71 | $this->buffer .= $output; 72 | $this->buffer .= "\t"; 73 | 74 | fwrite($this->file, $this->buffer . "\n\n"); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Runner/Output/Logger.php: -------------------------------------------------------------------------------- 1 | runner = $runner; 32 | $this->file = fopen($file ?: 'php://output', 'w'); 33 | } 34 | 35 | 36 | public function begin(): void 37 | { 38 | $this->count = 0; 39 | $this->results = [ 40 | Test::Passed => 0, 41 | Test::Skipped => 0, 42 | Test::Failed => 0, 43 | ]; 44 | fwrite($this->file, 'PHP ' . $this->runner->getInterpreter()->getVersion() 45 | . ' | ' . $this->runner->getInterpreter()->getCommandLine() 46 | . " | {$this->runner->threadCount} threads\n\n"); 47 | } 48 | 49 | 50 | public function prepare(Test $test): void 51 | { 52 | $this->count++; 53 | } 54 | 55 | 56 | public function finish(Test $test): void 57 | { 58 | $this->results[$test->getResult()]++; 59 | $message = ' ' . str_replace("\n", "\n ", Tester\Dumper::removeColors(trim((string) $test->message))); 60 | $outputs = [ 61 | Test::Passed => "-- OK: {$test->getSignature()}", 62 | Test::Skipped => "-- Skipped: {$test->getSignature()}\n$message", 63 | Test::Failed => "-- FAILED: {$test->getSignature()}\n$message", 64 | ]; 65 | fwrite($this->file, $outputs[$test->getResult()] . "\n\n"); 66 | } 67 | 68 | 69 | public function end(): void 70 | { 71 | $run = array_sum($this->results); 72 | fwrite( 73 | $this->file, 74 | ($this->results[Test::Failed] ? 'FAILURES!' : 'OK') 75 | . " ($this->count tests" 76 | . ($this->results[Test::Failed] ? ", {$this->results[Test::Failed]} failures" : '') 77 | . ($this->results[Test::Skipped] ? ", {$this->results[Test::Skipped]} skipped" : '') 78 | . ($this->count !== $run ? ', ' . ($this->count - $run) . ' not run' : '') 79 | . ')', 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/CodeCoverage/Generators/HtmlGenerator.php: -------------------------------------------------------------------------------- 1 | 't', // tested 24 | self::LineUntested => 'u', // untested 25 | self::LineDead => 'dead', // dead code 26 | ]; 27 | private ?string $title; 28 | private array $files = []; 29 | 30 | 31 | /** 32 | * @param string $file path to coverage.dat file 33 | * @param array $sources files/directories 34 | */ 35 | public function __construct(string $file, array $sources = [], ?string $title = null) 36 | { 37 | parent::__construct($file, $sources); 38 | $this->title = $title; 39 | } 40 | 41 | 42 | protected function renderSelf(): void 43 | { 44 | $this->setupHighlight(); 45 | $this->parse(); 46 | 47 | $title = $this->title; 48 | $classes = self::Classes; 49 | $files = $this->files; 50 | $coveredPercent = $this->getCoveredPercent(); 51 | 52 | include __DIR__ . '/template.phtml'; 53 | } 54 | 55 | 56 | private function setupHighlight(): void 57 | { 58 | ini_set('highlight.comment', 'hc'); 59 | ini_set('highlight.default', 'hd'); 60 | ini_set('highlight.html', 'hh'); 61 | ini_set('highlight.keyword', 'hk'); 62 | ini_set('highlight.string', 'hs'); 63 | } 64 | 65 | 66 | private function parse(): void 67 | { 68 | if (count($this->files) > 0) { 69 | return; 70 | } 71 | 72 | $this->files = []; 73 | $commonSourcesPath = Helpers::findCommonDirectory($this->sources) . DIRECTORY_SEPARATOR; 74 | foreach ($this->getSourceIterator() as $entry) { 75 | $entry = (string) $entry; 76 | 77 | $coverage = $covered = $total = 0; 78 | $loaded = !empty($this->data[$entry]); 79 | $lines = []; 80 | if ($loaded) { 81 | $lines = $this->data[$entry]; 82 | foreach ($lines as $flag) { 83 | if ($flag >= self::LineUntested) { 84 | $total++; 85 | } 86 | 87 | if ($flag >= self::LineTested) { 88 | $covered++; 89 | } 90 | } 91 | 92 | $coverage = round($covered * 100 / $total); 93 | $this->totalSum += $total; 94 | $this->coveredSum += $covered; 95 | } else { 96 | $this->totalSum += count(file($entry, FILE_SKIP_EMPTY_LINES)); 97 | } 98 | 99 | $light = $total ? $total < 5 : count(file($entry)) < 50; 100 | $this->files[] = (object) [ 101 | 'name' => str_replace($commonSourcesPath, '', $entry), 102 | 'file' => $entry, 103 | 'lines' => $lines, 104 | 'coverage' => $coverage, 105 | 'total' => $total, 106 | 'class' => $light ? 'light' : ($loaded ? null : 'not-loaded'), 107 | ]; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Framework/DataProvider.php: -------------------------------------------------------------------------------- 1 | require func_get_arg(0))(realpath($file)); 33 | 34 | if ($data instanceof \Traversable) { 35 | $data = iterator_to_array($data); 36 | } elseif (!is_array($data)) { 37 | throw new \Exception("Data provider '$file' did not return array or Traversable."); 38 | } 39 | } else { 40 | $data = @parse_ini_file($file, true, INI_SCANNER_TYPED); // @ is escalated to exception 41 | if ($data === false) { 42 | throw new \Exception("Cannot parse data provider file '$file'."); 43 | } 44 | } 45 | 46 | foreach ($data as $key => $value) { 47 | if (!self::testQuery((string) $key, $query)) { 48 | unset($data[$key]); 49 | } 50 | } 51 | 52 | return $data; 53 | } 54 | 55 | 56 | /** 57 | * Evaluates a query against a set of data keys to determine if the key matches the criteria. 58 | */ 59 | public static function testQuery(string $input, string $query): bool 60 | { 61 | $replaces = ['' => '=', '=>' => '>=', '=<' => '<=']; 62 | $tokens = preg_split('#\s+#', $input); 63 | preg_match_all('#\s*,?\s*(<=|=<|<|==|=|!=|<>|>=|=>|>)?\s*([^\s,]+)#A', $query, $queryParts, PREG_SET_ORDER); 64 | foreach ($queryParts as [, $operator, $operand]) { 65 | $operator = $replaces[$operator] ?? $operator; 66 | $token = (string) array_shift($tokens); 67 | $res = preg_match('#^[0-9.]+$#D', $token) 68 | ? version_compare($token, $operand, $operator) 69 | : self::compare($token, $operator, $operand); 70 | if (!$res) { 71 | return false; 72 | } 73 | } 74 | 75 | return true; 76 | } 77 | 78 | 79 | /** 80 | * Compares two values using the specified operator. 81 | */ 82 | private static function compare(mixed $l, string $operator, mixed $r): bool 83 | { 84 | return match ($operator) { 85 | '>' => $l > $r, 86 | '>=', '=>' => $l >= $r, 87 | '<' => $l < $r, 88 | '=<', '<=' => $l <= $r, 89 | '=', '==' => $l == $r, 90 | '!', '!=', '<>' => $l != $r, 91 | default => throw new \InvalidArgumentException("Unknown operator '$operator'"), 92 | }; 93 | } 94 | 95 | 96 | /** 97 | * Parses a data provider annotation from a test method to extract the file path and query. 98 | */ 99 | public static function parseAnnotation(string $annotation, string $file): array 100 | { 101 | if (!preg_match('#^(\??)\s*([^,\s]+)\s*,?\s*(\S.*)?()#', $annotation, $m)) { 102 | throw new \Exception("Invalid @dataProvider value '$annotation'."); 103 | } 104 | 105 | return [dirname($file) . DIRECTORY_SEPARATOR . $m[2], $m[3], (bool) $m[1]]; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Runner/Test.php: -------------------------------------------------------------------------------- 1 | file = $file; 48 | $this->title = $title; 49 | } 50 | 51 | 52 | public function getFile(): string 53 | { 54 | return $this->file; 55 | } 56 | 57 | 58 | /** 59 | * @return string[]|string[][] 60 | */ 61 | public function getArguments(): array 62 | { 63 | return $this->args; 64 | } 65 | 66 | 67 | public function getSignature(): string 68 | { 69 | $args = implode(' ', array_map(fn($arg): string => is_array($arg) ? "$arg[0]=$arg[1]" : $arg, $this->args)); 70 | 71 | return $this->file . ($args ? " $args" : ''); 72 | } 73 | 74 | 75 | public function getResult(): int 76 | { 77 | return $this->result; 78 | } 79 | 80 | 81 | public function hasResult(): bool 82 | { 83 | return $this->result !== self::Prepared; 84 | } 85 | 86 | 87 | /** 88 | * Duration in seconds. 89 | */ 90 | public function getDuration(): ?float 91 | { 92 | return $this->duration; 93 | } 94 | 95 | 96 | /** 97 | * Full output (stdout + stderr) 98 | */ 99 | public function getOutput(): string 100 | { 101 | return $this->stdout . ($this->stderr ? "\nSTDERR:\n" . $this->stderr : ''); 102 | } 103 | 104 | 105 | public function withTitle(string $title): self 106 | { 107 | if ($this->hasResult()) { 108 | throw new \LogicException('Cannot change title to test which already has a result.'); 109 | } 110 | 111 | $me = clone $this; 112 | $me->title = $title; 113 | return $me; 114 | } 115 | 116 | 117 | /** 118 | * @return static 119 | */ 120 | public function withArguments(array $args): self 121 | { 122 | if ($this->hasResult()) { 123 | throw new \LogicException('Cannot change arguments of test which already has a result.'); 124 | } 125 | 126 | $me = clone $this; 127 | foreach ($args as $name => $values) { 128 | foreach ((array) $values as $value) { 129 | $me->args[] = is_int($name) 130 | ? "$value" 131 | : [$name, "$value"]; 132 | } 133 | } 134 | 135 | return $me; 136 | } 137 | 138 | 139 | /** 140 | * @return static 141 | */ 142 | public function withResult(int $result, ?string $message, ?float $duration = null): self 143 | { 144 | if ($this->hasResult()) { 145 | throw new \LogicException("Result of test is already set to $this->result with message '$this->message'."); 146 | } 147 | 148 | $me = clone $this; 149 | $me->result = $result; 150 | $me->message = $message; 151 | $me->duration = $duration; 152 | return $me; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/CodeCoverage/Generators/AbstractGenerator.php: -------------------------------------------------------------------------------- 1 | data = @unserialize(file_get_contents($file)); // @ is escalated to exception 45 | if (!is_array($this->data)) { 46 | throw new \Exception("Content of file '$file' is invalid."); 47 | } 48 | 49 | $this->data = array_filter($this->data, fn(string $path): bool => @is_file($path), ARRAY_FILTER_USE_KEY); 50 | 51 | if (!$sources) { 52 | $sources = [Helpers::findCommonDirectory(array_keys($this->data))]; 53 | 54 | } else { 55 | foreach ($sources as $source) { 56 | if (!file_exists($source)) { 57 | throw new \Exception("File or directory '$source' is missing."); 58 | } 59 | } 60 | } 61 | 62 | $this->sources = array_map('realpath', $sources); 63 | } 64 | 65 | 66 | public function render(?string $file = null): void 67 | { 68 | $handle = $file ? @fopen($file, 'w') : STDOUT; // @ is escalated to exception 69 | if (!$handle) { 70 | throw new \Exception("Unable to write to file '$file'."); 71 | } 72 | 73 | ob_start(function (string $buffer) use ($handle) { 74 | fwrite($handle, $buffer); 75 | return ''; 76 | }, 4096); 77 | try { 78 | $this->renderSelf(); 79 | } catch (\Throwable $e) { 80 | } 81 | 82 | ob_end_flush(); 83 | fclose($handle); 84 | 85 | if (isset($e)) { 86 | if ($file) { 87 | unlink($file); 88 | } 89 | 90 | throw $e; 91 | } 92 | } 93 | 94 | 95 | public function getCoveredPercent(): float 96 | { 97 | return $this->totalSum ? $this->coveredSum * 100 / $this->totalSum : 0; 98 | } 99 | 100 | 101 | protected function getSourceIterator(): \Iterator 102 | { 103 | $iterator = new \AppendIterator; 104 | foreach ($this->sources as $source) { 105 | $iterator->append( 106 | is_dir($source) 107 | ? new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source)) 108 | : new \ArrayIterator([new \SplFileInfo($source)]), 109 | ); 110 | } 111 | 112 | return new \CallbackFilterIterator( 113 | $iterator, 114 | fn(\SplFileInfo $file): bool => $file->getBasename()[0] !== '.' // . or .. or .gitignore 115 | && in_array($file->getExtension(), $this->acceptFiles, true), 116 | ); 117 | } 118 | 119 | 120 | /** @deprecated */ 121 | protected static function getCommonFilesPath(array $files): string 122 | { 123 | return Helpers::findCommonDirectory($files); 124 | } 125 | 126 | 127 | abstract protected function renderSelf(): void; 128 | } 129 | -------------------------------------------------------------------------------- /src/Runner/PhpInterpreter.php: -------------------------------------------------------------------------------- 1 | commandLine = Helpers::escapeArg($path); 30 | $proc = @proc_open( // @ is escalated to exception 31 | $this->commandLine . ' --version', 32 | [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], 33 | $pipes, 34 | null, 35 | null, 36 | ['bypass_shell' => true], 37 | ); 38 | if ($proc === false) { 39 | throw new \Exception("Cannot run PHP interpreter $path. Use -p option."); 40 | } 41 | 42 | fclose($pipes[0]); 43 | $output = stream_get_contents($pipes[1]); 44 | proc_close($proc); 45 | 46 | $args = ' ' . implode(' ', array_map([Helpers::class, 'escapeArg'], $args)); 47 | if (str_contains($output, 'phpdbg')) { 48 | $args = ' -qrrb -S cli' . $args; 49 | } 50 | 51 | $this->commandLine .= rtrim($args); 52 | 53 | $proc = proc_open( 54 | $this->commandLine . ' -d register_argc_argv=on ' . Helpers::escapeArg(__DIR__ . '/info.php') . ' serialized', 55 | [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], 56 | $pipes, 57 | null, 58 | null, 59 | ['bypass_shell' => true], 60 | ); 61 | $output = stream_get_contents($pipes[1]); 62 | $this->error = trim(stream_get_contents($pipes[2])); 63 | if (proc_close($proc)) { 64 | throw new \Exception("Unable to run $path: " . preg_replace('#[\r\n ]+#', ' ', $this->error)); 65 | } 66 | 67 | $parts = explode("\r\n\r\n", $output, 2); 68 | $this->cgi = count($parts) === 2; 69 | $this->info = @unserialize((string) strstr($parts[$this->cgi], 'O:8:"stdClass"')); 70 | $this->error .= strstr($parts[$this->cgi], 'O:8:"stdClass"', before_needle: true); 71 | if (!$this->info) { 72 | throw new \Exception("Unable to detect PHP version (output: $output)."); 73 | 74 | } elseif ($this->cgi && $this->error) { 75 | $this->error .= "\n(note that PHP CLI generates better error messages)"; 76 | } 77 | } 78 | 79 | 80 | /** 81 | * @return static 82 | */ 83 | public function withPhpIniOption(string $name, ?string $value = null): self 84 | { 85 | $me = clone $this; 86 | $me->commandLine .= ' -d ' . Helpers::escapeArg($name . ($value === null ? '' : "=$value")); 87 | return $me; 88 | } 89 | 90 | 91 | public function getCommandLine(): string 92 | { 93 | return $this->commandLine; 94 | } 95 | 96 | 97 | public function getVersion(): string 98 | { 99 | return $this->info->version; 100 | } 101 | 102 | 103 | public function getCodeCoverageEngines(): array 104 | { 105 | return $this->info->codeCoverageEngines; 106 | } 107 | 108 | 109 | public function isCgi(): bool 110 | { 111 | return $this->cgi; 112 | } 113 | 114 | 115 | public function getStartupError(): string 116 | { 117 | return $this->error; 118 | } 119 | 120 | 121 | public function getShortInfo(): string 122 | { 123 | return "PHP {$this->info->version} ({$this->info->sapi})" 124 | . ($this->info->phpDbgVersion ? "; PHPDBG {$this->info->phpDbgVersion}" : ''); 125 | } 126 | 127 | 128 | public function hasExtension(string $name): bool 129 | { 130 | return in_array(strtolower($name), array_map('strtolower', $this->info->extensions), true); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Framework/Expect.php: -------------------------------------------------------------------------------- 1 | */ 55 | private array $constraints = []; 56 | 57 | 58 | public static function __callStatic(string $method, array $args): self 59 | { 60 | $me = new self; 61 | $me->constraints[] = (object) ['method' => $method, 'args' => $args]; 62 | return $me; 63 | } 64 | 65 | 66 | public static function that(callable $constraint): self 67 | { 68 | return (new self)->and($constraint); 69 | } 70 | 71 | 72 | public function __call(string $method, array $args): self 73 | { 74 | if (preg_match('#^and([A-Z]\w+)#', $method, $m)) { 75 | $this->constraints[] = (object) ['method' => lcfirst($m[1]), 'args' => $args]; 76 | return $this; 77 | } 78 | 79 | throw new \Error('Call to undefined method ' . self::class . '::' . $method . '()'); 80 | } 81 | 82 | 83 | public function and(callable $constraint): self 84 | { 85 | $this->constraints[] = $constraint; 86 | return $this; 87 | } 88 | 89 | 90 | /** 91 | * Checks the expectations. 92 | */ 93 | public function __invoke(mixed $actual): void 94 | { 95 | foreach ($this->constraints as $cstr) { 96 | if ($cstr instanceof \stdClass) { 97 | $args = $cstr->args; 98 | $args[] = $actual; 99 | Assert::{$cstr->method}(...$args); 100 | 101 | } elseif ($cstr($actual) === false) { 102 | Assert::fail('%1 is expected to be %2', $actual, is_string($cstr) ? $cstr : 'user-expectation'); 103 | } 104 | } 105 | } 106 | 107 | 108 | public function dump(): string 109 | { 110 | $res = []; 111 | foreach ($this->constraints as $cstr) { 112 | if ($cstr instanceof \stdClass) { 113 | $args = isset($cstr->args[0]) 114 | ? Dumper::toLine($cstr->args[0]) 115 | : ''; 116 | $res[] = "$cstr->method($args)"; 117 | 118 | } elseif ($cstr instanceof self) { 119 | $res[] = $cstr->dump(); 120 | 121 | } else { 122 | $res[] = is_string($cstr) ? $cstr : 'user-expectation'; 123 | } 124 | } 125 | 126 | return implode(',', $res); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Framework/Helpers.php: -------------------------------------------------------------------------------- 1 | isDir()) { 36 | rmdir((string) $entry); 37 | } else { 38 | unlink((string) $entry); 39 | } 40 | } 41 | } 42 | 43 | 44 | /** 45 | * Find common directory for given paths. All files or directories must exist. 46 | * @return string Empty when not found. Slash and back slash chars normalized to DIRECTORY_SEPARATOR. 47 | * @internal 48 | */ 49 | public static function findCommonDirectory(array $paths): string 50 | { 51 | $splitPaths = array_map(function ($s) { 52 | $real = realpath($s); 53 | if ($s === '') { 54 | throw new \RuntimeException('Path must not be empty.'); 55 | } elseif ($real === false) { 56 | throw new \RuntimeException("File or directory '$s' does not exist."); 57 | } 58 | 59 | return explode(DIRECTORY_SEPARATOR, $real); 60 | }, $paths); 61 | 62 | $first = (array) array_shift($splitPaths); 63 | for ($i = 0; $i < count($first); $i++) { 64 | foreach ($splitPaths as $s) { 65 | if ($first[$i] !== ($s[$i] ?? null)) { 66 | break 2; 67 | } 68 | } 69 | } 70 | 71 | $common = implode(DIRECTORY_SEPARATOR, array_slice($first, 0, $i)); 72 | return is_dir($common) ? $common : dirname($common); 73 | } 74 | 75 | 76 | /** 77 | * Parse the first docblock encountered in the provided string. 78 | * @internal 79 | */ 80 | public static function parseDocComment(string $s): array 81 | { 82 | $options = []; 83 | if (!preg_match('#^/\*\*(.*?)\*/#ms', $s, $content)) { 84 | return []; 85 | } 86 | 87 | if (preg_match('#^[ \t\*]*+([^\s@].*)#mi', $content[1], $matches)) { 88 | $options[0] = trim($matches[1]); 89 | } 90 | 91 | preg_match_all('#^[ \t\*]*@(\w+)([^\w\r\n].*)?#mi', $content[1], $matches, PREG_SET_ORDER); 92 | foreach ($matches as $match) { 93 | $ref = &$options[strtolower($match[1])]; 94 | if (isset($ref)) { 95 | $ref = (array) $ref; 96 | $ref = &$ref[]; 97 | } 98 | 99 | $ref = isset($match[2]) ? trim($match[2]) : ''; 100 | } 101 | 102 | return $options; 103 | } 104 | 105 | 106 | /** 107 | * @internal 108 | */ 109 | public static function errorTypeToString(int $type): string 110 | { 111 | $consts = get_defined_constants(true); 112 | foreach ($consts['Core'] as $name => $val) { 113 | if ($type === $val && substr($name, 0, 2) === 'E_') { 114 | return $name; 115 | } 116 | } 117 | 118 | return 'Unknown error'; 119 | } 120 | 121 | 122 | /** 123 | * Escape a string to be used as a shell argument. 124 | * @internal 125 | */ 126 | public static function escapeArg(string $s): string 127 | { 128 | if (preg_match('#^[a-z0-9._=/:-]+$#Di', $s)) { 129 | return $s; 130 | } 131 | 132 | return defined('PHP_WINDOWS_VERSION_BUILD') 133 | ? '"' . str_replace('"', '""', $s) . '"' 134 | : escapeshellarg($s); 135 | } 136 | 137 | 138 | /** 139 | * @internal 140 | */ 141 | public static function prepareTempDir(string $path): string 142 | { 143 | $real = realpath($path); 144 | if ($real === false || !is_dir($real) || !is_writable($real)) { 145 | throw new \RuntimeException("Path '$real' is not a writable directory."); 146 | } 147 | 148 | $path = $real . DIRECTORY_SEPARATOR . 'Tester'; 149 | if (!is_dir($path) && @mkdir($path) === false && !is_dir($path)) { // @ - directory may exist 150 | throw new \RuntimeException("Cannot create '$path' directory."); 151 | } 152 | 153 | return $path; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/CodeCoverage/Collector.php: -------------------------------------------------------------------------------- 1 | $engineInfo[0], self::detectEngines()), 59 | true, 60 | )) { 61 | throw new \LogicException("Code coverage engine '$engine' is not supported."); 62 | } 63 | 64 | self::$file = fopen($file, 'c+'); 65 | self::$engine = $engine; 66 | self::{'start' . $engine}(); 67 | 68 | register_shutdown_function(function (): void { 69 | register_shutdown_function([self::class, 'save']); 70 | }); 71 | } 72 | 73 | 74 | /** 75 | * Flushes all gathered information. Effective only with PHPDBG collector. 76 | */ 77 | public static function flush(): void 78 | { 79 | if (self::isStarted() && self::$engine === self::EnginePhpdbg) { 80 | self::save(); 81 | } 82 | } 83 | 84 | 85 | /** 86 | * Saves information about code coverage. Can be called repeatedly to free memory. 87 | * @throws \LogicException 88 | */ 89 | public static function save(): void 90 | { 91 | if (!self::isStarted()) { 92 | throw new \LogicException('Code coverage collector has not been started.'); 93 | } 94 | 95 | [$positive, $negative] = self::{'collect' . self::$engine}(); 96 | 97 | flock(self::$file, LOCK_EX); 98 | fseek(self::$file, 0); 99 | $rawContent = stream_get_contents(self::$file); 100 | $original = $rawContent ? unserialize($rawContent) : []; 101 | $coverage = array_replace_recursive($negative, $original, $positive); 102 | 103 | fseek(self::$file, 0); 104 | ftruncate(self::$file, 0); 105 | fwrite(self::$file, serialize($coverage)); 106 | flock(self::$file, LOCK_UN); 107 | } 108 | 109 | 110 | private static function startPCOV(): void 111 | { 112 | pcov\start(); 113 | } 114 | 115 | 116 | /** 117 | * Collects information about code coverage. 118 | */ 119 | private static function collectPCOV(): array 120 | { 121 | $positive = $negative = []; 122 | 123 | pcov\stop(); 124 | 125 | foreach (pcov\collect() as $file => $lines) { 126 | if (!file_exists($file)) { 127 | continue; 128 | } 129 | 130 | foreach ($lines as $num => $val) { 131 | if ($val > 0) { 132 | $positive[$file][$num] = $val; 133 | } else { 134 | $negative[$file][$num] = $val; 135 | } 136 | } 137 | } 138 | 139 | return [$positive, $negative]; 140 | } 141 | 142 | 143 | private static function startXdebug(): void 144 | { 145 | xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); 146 | } 147 | 148 | 149 | /** 150 | * Collects information about code coverage. 151 | */ 152 | private static function collectXdebug(): array 153 | { 154 | $positive = $negative = []; 155 | 156 | foreach (xdebug_get_code_coverage() as $file => $lines) { 157 | if (!file_exists($file)) { 158 | continue; 159 | } 160 | 161 | foreach ($lines as $num => $val) { 162 | if ($val > 0) { 163 | $positive[$file][$num] = $val; 164 | } else { 165 | $negative[$file][$num] = $val; 166 | } 167 | } 168 | } 169 | 170 | return [$positive, $negative]; 171 | } 172 | 173 | 174 | private static function startPhpDbg(): void 175 | { 176 | phpdbg_start_oplog(); 177 | } 178 | 179 | 180 | /** 181 | * Collects information about code coverage. 182 | */ 183 | private static function collectPhpDbg(): array 184 | { 185 | $positive = phpdbg_end_oplog(); 186 | $negative = phpdbg_get_executable(); 187 | 188 | foreach ($positive as $file => &$lines) { 189 | $lines = array_fill_keys(array_keys($lines), 1); 190 | } 191 | 192 | foreach ($negative as $file => &$lines) { 193 | $lines = array_fill_keys(array_keys($lines), -1); 194 | } 195 | 196 | phpdbg_start_oplog(); 197 | return [$positive, $negative]; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/CodeCoverage/PhpParser.php: -------------------------------------------------------------------------------- 1 | $functionInfo], 32 | * classes: [className => $info], 33 | * traits: [traitName => $info], 34 | * interfaces: [interfaceName => $info], 35 | * } 36 | * 37 | * where $functionInfo is: 38 | * stdClass { 39 | * start: int, 40 | * end: int 41 | * } 42 | * 43 | * and $info is: 44 | * stdClass { 45 | * start: int, 46 | * end: int, 47 | * methods: [methodName => $methodInfo] 48 | * } 49 | * 50 | * where $methodInfo is: 51 | * stdClass { 52 | * start: int, 53 | * end: int, 54 | * visibility: public|protected|private 55 | * } 56 | */ 57 | public function parse(string $code): \stdClass 58 | { 59 | $tokens = \PhpToken::tokenize($code, TOKEN_PARSE); 60 | 61 | $level = $classLevel = $functionLevel = null; 62 | $namespace = ''; 63 | $line = 1; 64 | 65 | $result = (object) [ 66 | 'linesOfCode' => max(1, substr_count($code, "\n")), 67 | 'linesOfComments' => 0, 68 | 'functions' => [], 69 | 'classes' => [], 70 | 'traits' => [], 71 | 'interfaces' => [], 72 | ]; 73 | 74 | while ($token = current($tokens)) { 75 | next($tokens); 76 | $line = $token->line; 77 | 78 | switch ($token->id) { 79 | case T_NAMESPACE: 80 | $namespace = self::fetch($tokens, [T_STRING, T_NAME_QUALIFIED]); 81 | $namespace = ltrim($namespace . '\\', '\\'); 82 | break; 83 | 84 | case T_CLASS: 85 | case T_INTERFACE: 86 | case T_TRAIT: 87 | if ($name = self::fetch($tokens, T_STRING)) { 88 | if ($token->id === T_CLASS) { 89 | $class = &$result->classes[$namespace . $name]; 90 | } elseif ($token->id === T_INTERFACE) { 91 | $class = &$result->interfaces[$namespace . $name]; 92 | } else { 93 | $class = &$result->traits[$namespace . $name]; 94 | } 95 | 96 | $classLevel = $level + 1; 97 | $class = (object) [ 98 | 'start' => $line, 99 | 'end' => null, 100 | 'methods' => [], 101 | ]; 102 | } 103 | 104 | break; 105 | 106 | case T_PUBLIC: 107 | case T_PROTECTED: 108 | case T_PRIVATE: 109 | $visibility = $token->text; 110 | break; 111 | 112 | case T_ABSTRACT: 113 | $isAbstract = true; 114 | break; 115 | 116 | case T_FUNCTION: 117 | if (($name = self::fetch($tokens, T_STRING)) && !isset($isAbstract)) { 118 | if (isset($class) && $level === $classLevel) { 119 | $function = &$class->methods[$name]; 120 | $function = (object) [ 121 | 'start' => $line, 122 | 'end' => null, 123 | 'visibility' => $visibility ?? 'public', 124 | ]; 125 | 126 | } else { 127 | $function = &$result->functions[$namespace . $name]; 128 | $function = (object) [ 129 | 'start' => $line, 130 | 'end' => null, 131 | ]; 132 | } 133 | 134 | $functionLevel = $level + 1; 135 | } 136 | 137 | unset($visibility, $isAbstract); 138 | break; 139 | 140 | case T_CURLY_OPEN: 141 | case T_DOLLAR_OPEN_CURLY_BRACES: 142 | case ord('{'): 143 | $level++; 144 | break; 145 | 146 | case ord('}'): 147 | if (isset($function) && $level === $functionLevel) { 148 | $function->end = $line; 149 | unset($function); 150 | 151 | } elseif (isset($class) && $level === $classLevel) { 152 | $class->end = $line; 153 | unset($class); 154 | } 155 | 156 | $level--; 157 | break; 158 | 159 | case T_COMMENT: 160 | case T_DOC_COMMENT: 161 | $result->linesOfComments += substr_count(trim($token->text), "\n") + 1; 162 | // break omitted 163 | 164 | case T_WHITESPACE: 165 | case T_CONSTANT_ENCAPSED_STRING: 166 | $line += substr_count($token->text, "\n"); 167 | break; 168 | } 169 | } 170 | 171 | return $result; 172 | } 173 | 174 | 175 | private static function fetch(array &$tokens, array|int $take): ?string 176 | { 177 | $res = null; 178 | while ($token = current($tokens)) { 179 | if ($token->is($take)) { 180 | $res .= $token->text; 181 | } elseif (!$token->is([T_DOC_COMMENT, T_WHITESPACE, T_COMMENT])) { 182 | break; 183 | } 184 | 185 | next($tokens); 186 | } 187 | 188 | return $res; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Runner/Job.php: -------------------------------------------------------------------------------- 1 | getResult() !== Test::Prepared) { 54 | throw new \LogicException("Test '{$test->getSignature()}' already has result '{$test->getResult()}'."); 55 | } 56 | 57 | $test->stdout = ''; 58 | $test->stderr = ''; 59 | 60 | $this->test = $test; 61 | $this->interpreter = $interpreter; 62 | $this->envVars = (array) $envVars; 63 | } 64 | 65 | 66 | public function setTempDirectory(?string $path): void 67 | { 68 | $this->stderrFile = $path === null 69 | ? null 70 | : $path . DIRECTORY_SEPARATOR . 'Job.pid-' . getmypid() . '.' . uniqid() . '.stderr'; 71 | } 72 | 73 | 74 | public function setEnvironmentVariable(string $name, string $value): void 75 | { 76 | $this->envVars[$name] = $value; 77 | } 78 | 79 | 80 | public function getEnvironmentVariable(string $name): string 81 | { 82 | return $this->envVars[$name]; 83 | } 84 | 85 | 86 | /** 87 | * Runs single test. 88 | */ 89 | public function run(bool $async = false): void 90 | { 91 | foreach ($this->envVars as $name => $value) { 92 | putenv("$name=$value"); 93 | } 94 | 95 | $args = []; 96 | foreach ($this->test->getArguments() as $value) { 97 | $args[] = is_array($value) 98 | ? Helpers::escapeArg("--$value[0]=$value[1]") 99 | : Helpers::escapeArg($value); 100 | } 101 | 102 | $this->duration = -microtime(true); 103 | $this->proc = proc_open( 104 | $this->interpreter->getCommandLine() 105 | . ' -d register_argc_argv=on ' . Helpers::escapeArg($this->test->getFile()) . ' ' . implode(' ', $args), 106 | [ 107 | ['pipe', 'r'], 108 | ['pipe', 'w'], 109 | $this->stderrFile ? ['file', $this->stderrFile, 'w'] : ['pipe', 'w'], 110 | ], 111 | $pipes, 112 | dirname($this->test->getFile()), 113 | null, 114 | ['bypass_shell' => true], 115 | ); 116 | 117 | foreach (array_keys($this->envVars) as $name) { 118 | putenv($name); 119 | } 120 | 121 | [$stdin, $this->stdout] = $pipes; 122 | fclose($stdin); 123 | 124 | if (isset($pipes[2])) { 125 | fclose($pipes[2]); 126 | } 127 | 128 | if ($async) { 129 | stream_set_blocking($this->stdout, enable: false); // on Windows does not work with proc_open() 130 | } else { 131 | while ($this->isRunning()) { 132 | usleep(self::RunSleep); // stream_select() doesn't work with proc_open() 133 | } 134 | } 135 | } 136 | 137 | 138 | /** 139 | * Checks if the test is still running. 140 | */ 141 | public function isRunning(): bool 142 | { 143 | if (!is_resource($this->stdout)) { 144 | return false; 145 | } 146 | 147 | $this->test->stdout .= stream_get_contents($this->stdout); 148 | 149 | $status = proc_get_status($this->proc); 150 | if ($status['running']) { 151 | return true; 152 | } 153 | 154 | $this->duration += microtime(true); 155 | 156 | fclose($this->stdout); 157 | if ($this->stderrFile) { 158 | $this->test->stderr .= file_get_contents($this->stderrFile); 159 | unlink($this->stderrFile); 160 | } 161 | 162 | $code = proc_close($this->proc); 163 | $this->exitCode = $code === self::CodeNone 164 | ? $status['exitcode'] 165 | : $code; 166 | 167 | if ($this->interpreter->isCgi() && count($tmp = explode("\r\n\r\n", $this->test->stdout, 2)) >= 2) { 168 | [$headers, $this->test->stdout] = $tmp; 169 | foreach (explode("\r\n", $headers) as $header) { 170 | $pos = strpos($header, ':'); 171 | if ($pos !== false) { 172 | $this->headers[trim(substr($header, 0, $pos))] = trim(substr($header, $pos + 1)); 173 | } 174 | } 175 | } 176 | 177 | return false; 178 | } 179 | 180 | 181 | public function getTest(): Test 182 | { 183 | return $this->test; 184 | } 185 | 186 | 187 | /** 188 | * Returns exit code. 189 | */ 190 | public function getExitCode(): int 191 | { 192 | return $this->exitCode; 193 | } 194 | 195 | 196 | /** 197 | * Returns output headers. 198 | * @return string[] 199 | */ 200 | public function getHeaders(): array 201 | { 202 | return $this->headers; 203 | } 204 | 205 | 206 | /** 207 | * Returns process duration in seconds. 208 | */ 209 | public function getDuration(): ?float 210 | { 211 | return $this->duration > 0 212 | ? $this->duration 213 | : null; 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/Runner/CommandLine.php: -------------------------------------------------------------------------------- 1 | help = $help; 44 | $this->options = $defaults; 45 | 46 | preg_match_all('#^[ \t]+(--?\w.*?)(?: .*\(default: (.*)\)| |\r|$)#m', $help, $lines, PREG_SET_ORDER); 47 | foreach ($lines as $line) { 48 | preg_match_all('#(--?\w[\w-]*)(?:[= ](<.*?>|\[.*?]|\w+)(\.{0,3}))?[ ,|]*#A', $line[1], $m); 49 | if (!count($m[0]) || count($m[0]) > 2 || implode('', $m[0]) !== $line[1]) { 50 | throw new \InvalidArgumentException("Unable to parse '$line[1]'."); 51 | } 52 | 53 | $name = end($m[1]); 54 | $opts = $this->options[$name] ?? []; 55 | $this->options[$name] = $opts + [ 56 | self::Argument => (bool) end($m[2]), 57 | self::Optional => isset($line[2]) || (substr(end($m[2]), 0, 1) === '[') || isset($opts[self::Value]), 58 | self::Repeatable => (bool) end($m[3]), 59 | self::Enum => count($enums = explode('|', trim(end($m[2]), '<[]>'))) > 1 ? $enums : null, 60 | self::Value => $line[2] ?? null, 61 | ]; 62 | if ($name !== $m[1][0]) { 63 | $this->aliases[$m[1][0]] = $name; 64 | } 65 | } 66 | 67 | foreach ($this->options as $name => $foo) { 68 | if ($name[0] !== '-') { 69 | $this->positional[] = $name; 70 | } 71 | } 72 | } 73 | 74 | 75 | public function parse(?array $args = null): array 76 | { 77 | if ($args === null) { 78 | $args = isset($_SERVER['argv']) ? array_slice($_SERVER['argv'], 1) : []; 79 | } 80 | 81 | $params = []; 82 | reset($this->positional); 83 | $i = 0; 84 | while ($i < count($args)) { 85 | $arg = $args[$i++]; 86 | if ($arg[0] !== '-') { 87 | if (!current($this->positional)) { 88 | throw new \Exception("Unexpected parameter $arg."); 89 | } 90 | 91 | $name = current($this->positional); 92 | $this->checkArg($this->options[$name], $arg); 93 | if (empty($this->options[$name][self::Repeatable])) { 94 | $params[$name] = $arg; 95 | next($this->positional); 96 | } else { 97 | $params[$name][] = $arg; 98 | } 99 | 100 | continue; 101 | } 102 | 103 | [$name, $arg] = strpos($arg, '=') ? explode('=', $arg, 2) : [$arg, true]; 104 | 105 | if (isset($this->aliases[$name])) { 106 | $name = $this->aliases[$name]; 107 | 108 | } elseif (!isset($this->options[$name])) { 109 | throw new \Exception("Unknown option $name."); 110 | } 111 | 112 | $opt = $this->options[$name]; 113 | 114 | if ($arg !== true && empty($opt[self::Argument])) { 115 | throw new \Exception("Option $name has not argument."); 116 | 117 | } elseif ($arg === true && !empty($opt[self::Argument])) { 118 | if (isset($args[$i]) && $args[$i][0] !== '-') { 119 | $arg = $args[$i++]; 120 | } elseif (empty($opt[self::Optional])) { 121 | throw new \Exception("Option $name requires argument."); 122 | } 123 | } 124 | 125 | $this->checkArg($opt, $arg); 126 | 127 | if ( 128 | !empty($opt[self::Enum]) 129 | && !in_array(is_array($arg) ? reset($arg) : $arg, $opt[self::Enum], true) 130 | && !( 131 | $opt[self::Optional] 132 | && $arg === true 133 | ) 134 | ) { 135 | throw new \Exception("Value of option $name must be " . implode(', or ', $opt[self::Enum]) . '.'); 136 | } 137 | 138 | if (empty($opt[self::Repeatable])) { 139 | $params[$name] = $arg; 140 | } else { 141 | $params[$name][] = $arg; 142 | } 143 | } 144 | 145 | foreach ($this->options as $name => $opt) { 146 | if (isset($params[$name])) { 147 | continue; 148 | } elseif (isset($opt[self::Value])) { 149 | $params[$name] = $opt[self::Value]; 150 | } elseif ($name[0] !== '-' && empty($opt[self::Optional])) { 151 | throw new \Exception("Missing required argument <$name>."); 152 | } else { 153 | $params[$name] = null; 154 | } 155 | 156 | if (!empty($opt[self::Repeatable])) { 157 | $params[$name] = (array) $params[$name]; 158 | } 159 | } 160 | 161 | return $params; 162 | } 163 | 164 | 165 | public function help(): void 166 | { 167 | echo $this->help; 168 | } 169 | 170 | 171 | public function checkArg(array $opt, mixed &$arg): void 172 | { 173 | if (!empty($opt[self::Normalizer])) { 174 | $arg = call_user_func($opt[self::Normalizer], $arg); 175 | } 176 | 177 | if (!empty($opt[self::RealPath])) { 178 | $path = realpath($arg); 179 | if ($path === false) { 180 | throw new \Exception("File path '$arg' not found."); 181 | } 182 | 183 | $arg = $path; 184 | } 185 | } 186 | 187 | 188 | public function isEmpty(): bool 189 | { 190 | return !isset($_SERVER['argv']) || count($_SERVER['argv']) < 2; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/Framework/FileMock.php: -------------------------------------------------------------------------------- 1 | warning("failed to open stream: Invalid mode '$mode'"); 65 | return false; 66 | 67 | } elseif ($m[1] === 'x' && isset(self::$files[$path])) { 68 | $this->warning('failed to open stream: File exists'); 69 | return false; 70 | 71 | } elseif ($m[1] === 'r' && !isset(self::$files[$path])) { 72 | $this->warning('failed to open stream: No such file or directory'); 73 | return false; 74 | 75 | } elseif ($m[1] === 'w' || $m[1] === 'x') { 76 | self::$files[$path] = ''; 77 | } 78 | 79 | $tmp = &self::$files[$path]; 80 | $tmp = (string) $tmp; 81 | $this->content = &$tmp; 82 | $this->appendMode = $m[1] === 'a'; 83 | $this->readingPos = 0; 84 | $this->writingPos = $this->appendMode ? strlen($this->content) : 0; 85 | $this->isReadable = isset($m[2]) || $m[1] === 'r'; 86 | $this->isWritable = isset($m[2]) || $m[1] !== 'r'; 87 | 88 | return true; 89 | } 90 | 91 | 92 | public function stream_read(int $length) 93 | { 94 | if (!$this->isReadable) { 95 | return false; 96 | } 97 | 98 | $result = substr($this->content, $this->readingPos, $length); 99 | $this->readingPos += strlen($result); 100 | $this->writingPos += $this->appendMode ? 0 : strlen($result); 101 | return $result; 102 | } 103 | 104 | 105 | public function stream_write(string $data) 106 | { 107 | if (!$this->isWritable) { 108 | return false; 109 | } 110 | 111 | $length = strlen($data); 112 | $this->content = str_pad($this->content, $this->writingPos, "\x00"); 113 | $this->content = substr_replace($this->content, $data, $this->writingPos, $length); 114 | $this->readingPos += $length; 115 | $this->writingPos += $length; 116 | return $length; 117 | } 118 | 119 | 120 | public function stream_tell(): int 121 | { 122 | return $this->readingPos; 123 | } 124 | 125 | 126 | public function stream_eof(): bool 127 | { 128 | return $this->readingPos >= strlen($this->content); 129 | } 130 | 131 | 132 | public function stream_seek(int $offset, int $whence): bool 133 | { 134 | if ($whence === SEEK_CUR) { 135 | $offset += $this->readingPos; 136 | } elseif ($whence === SEEK_END) { 137 | $offset += strlen($this->content); 138 | } 139 | 140 | if ($offset >= 0) { 141 | $this->readingPos = $offset; 142 | $this->writingPos = $this->appendMode ? $this->writingPos : $offset; 143 | return true; 144 | } else { 145 | return false; 146 | } 147 | } 148 | 149 | 150 | public function stream_truncate(int $size): bool 151 | { 152 | if (!$this->isWritable) { 153 | return false; 154 | } 155 | 156 | $this->content = substr(str_pad($this->content, $size, "\x00"), 0, $size); 157 | $this->writingPos = $this->appendMode ? $size : $this->writingPos; 158 | return true; 159 | } 160 | 161 | 162 | public function stream_set_option(int $option, int $arg1, int $arg2): bool 163 | { 164 | return false; 165 | } 166 | 167 | 168 | public function stream_stat(): array 169 | { 170 | return ['mode' => 0100666, 'size' => strlen($this->content)]; 171 | } 172 | 173 | 174 | public function url_stat(string $path, int $flags) 175 | { 176 | return isset(self::$files[$path]) 177 | ? ['mode' => 0100666, 'size' => strlen(self::$files[$path])] 178 | : false; 179 | } 180 | 181 | 182 | public function stream_lock(int $operation): bool 183 | { 184 | return false; 185 | } 186 | 187 | 188 | public function stream_metadata(string $path, int $option, $value): bool 189 | { 190 | switch ($option) { 191 | case STREAM_META_TOUCH: 192 | return true; 193 | } 194 | 195 | return false; 196 | } 197 | 198 | 199 | public function unlink(string $path): bool 200 | { 201 | if (isset(self::$files[$path])) { 202 | unset(self::$files[$path]); 203 | return true; 204 | } 205 | 206 | $this->warning('No such file'); 207 | return false; 208 | } 209 | 210 | 211 | private function warning(string $message): void 212 | { 213 | $bt = debug_backtrace(0, 3); 214 | if (isset($bt[2]['function'])) { 215 | $message = $bt[2]['function'] . '(' . @$bt[2]['args'][0] . '): ' . $message; 216 | } 217 | 218 | trigger_error($message, E_USER_WARNING); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/Runner/Output/ConsolePrinter.php: -------------------------------------------------------------------------------- 1 | runner = $runner; 46 | $this->displaySkipped = $displaySkipped; 47 | $this->file = fopen($file ?: 'php://output', 'w'); 48 | $this->symbols = match (true) { 49 | $ciderMode => [Test::Passed => '🍏', Test::Skipped => 's', Test::Failed => '🍎'], 50 | $lineMode => [Test::Passed => Dumper::color('lime', 'OK'), Test::Skipped => Dumper::color('yellow', 'SKIP'), Test::Failed => Dumper::color('white/red', 'FAIL')], 51 | default => [Test::Passed => '.', Test::Skipped => 's', Test::Failed => Dumper::color('white/red', 'F')], 52 | }; 53 | } 54 | 55 | 56 | public function begin(): void 57 | { 58 | $this->count = 0; 59 | $this->buffer = ''; 60 | $this->baseDir = null; 61 | $this->results = [ 62 | Test::Passed => 0, 63 | Test::Skipped => 0, 64 | Test::Failed => 0, 65 | ]; 66 | $this->time = -microtime(true); 67 | fwrite($this->file, $this->runner->getInterpreter()->getShortInfo() 68 | . ' | ' . $this->runner->getInterpreter()->getCommandLine() 69 | . " | {$this->runner->threadCount} thread" . ($this->runner->threadCount > 1 ? 's' : '') . "\n\n"); 70 | } 71 | 72 | 73 | public function prepare(Test $test): void 74 | { 75 | if ($this->baseDir === null) { 76 | $this->baseDir = dirname($test->getFile()) . DIRECTORY_SEPARATOR; 77 | } elseif (!str_starts_with($test->getFile(), $this->baseDir)) { 78 | $common = array_intersect_assoc( 79 | explode(DIRECTORY_SEPARATOR, $this->baseDir), 80 | explode(DIRECTORY_SEPARATOR, $test->getFile()), 81 | ); 82 | $this->baseDir = ''; 83 | $prev = 0; 84 | foreach ($common as $i => $part) { 85 | if ($i !== $prev++) { 86 | break; 87 | } 88 | 89 | $this->baseDir .= $part . DIRECTORY_SEPARATOR; 90 | } 91 | } 92 | 93 | $this->count++; 94 | } 95 | 96 | 97 | public function finish(Test $test): void 98 | { 99 | $this->results[$test->getResult()]++; 100 | fwrite( 101 | $this->file, 102 | $this->lineMode 103 | ? $this->generateFinishLine($test) 104 | : $this->symbols[$test->getResult()], 105 | ); 106 | 107 | $title = ($test->title ? "$test->title | " : '') . substr($test->getSignature(), strlen($this->baseDir)); 108 | $message = ' ' . str_replace("\n", "\n ", trim((string) $test->message)) . "\n\n"; 109 | $message = preg_replace('/^ $/m', '', $message); 110 | if ($test->getResult() === Test::Failed) { 111 | $this->buffer .= Dumper::color('red', "-- FAILED: $title") . "\n$message"; 112 | } elseif ($test->getResult() === Test::Skipped && $this->displaySkipped) { 113 | $this->buffer .= "-- Skipped: $title\n$message"; 114 | } 115 | } 116 | 117 | 118 | public function end(): void 119 | { 120 | $run = array_sum($this->results); 121 | fwrite($this->file, !$this->count ? "No tests found\n" : 122 | "\n\n" . $this->buffer . "\n" 123 | . ($this->results[Test::Failed] ? Dumper::color('white/red') . 'FAILURES!' : Dumper::color('white/green') . 'OK') 124 | . " ($this->count test" . ($this->count > 1 ? 's' : '') . ', ' 125 | . ($this->results[Test::Failed] ? $this->results[Test::Failed] . ' failure' . ($this->results[Test::Failed] > 1 ? 's' : '') . ', ' : '') 126 | . ($this->results[Test::Skipped] ? $this->results[Test::Skipped] . ' skipped, ' : '') 127 | . ($this->count !== $run ? ($this->count - $run) . ' not run, ' : '') 128 | . sprintf('%0.1f', $this->time + microtime(true)) . ' seconds)' . Dumper::color() . "\n"); 129 | 130 | $this->buffer = ''; 131 | } 132 | 133 | 134 | private function generateFinishLine(Test $test): string 135 | { 136 | $result = $test->getResult(); 137 | 138 | $shortFilePath = str_replace($this->baseDir, '', $test->getFile()); 139 | $shortDirPath = dirname($shortFilePath) . DIRECTORY_SEPARATOR; 140 | $basename = basename($shortFilePath); 141 | $fileText = $result === Test::Failed 142 | ? Dumper::color('red', $shortDirPath) . Dumper::color('white/red', $basename) 143 | : Dumper::color('gray', $shortDirPath) . Dumper::color('silver', $basename); 144 | 145 | $header = '· '; 146 | $titleText = $test->title 147 | ? Dumper::color('fuchsia', " [$test->title]") 148 | : ''; 149 | 150 | // failed tests messages will be printed after all tests are finished 151 | $message = ''; 152 | if ($result !== Test::Failed && $test->message) { 153 | $indent = str_repeat(' ', mb_strlen($header)); 154 | $message = preg_match('#\n#', $test->message) 155 | ? "\n$indent" . preg_replace('#\r?\n#', '\0' . $indent, $test->message) 156 | : Dumper::color('olive', "[$test->message]"); 157 | } 158 | 159 | return sprintf( 160 | "%s%s/%s %s%s %s %s %s\n", 161 | $header, 162 | array_sum($this->results), 163 | $this->count, 164 | $fileText, 165 | $titleText, 166 | $this->symbols[$result], 167 | Dumper::color('gray', sprintf('in %.2f s', $test->getDuration())), 168 | $message, 169 | ); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Framework/DomQuery.php: -------------------------------------------------------------------------------- 1 | ' . $html; 32 | } 33 | 34 | $html = @mb_convert_encoding($html, 'HTML', 'UTF-8'); // @ - deprecated 35 | 36 | // parse these elements as void 37 | $html = preg_replace('#<(keygen|source|track|wbr)(?=\s|>)((?:"[^"]*"|\'[^\']*\'|[^"\'>])*+)(?#', '<$1$2 />', $html); 38 | 39 | // fix parsing of )(?:"[^"]*"|\'[^\']*\'|[^"\'>])*+>)(.*?)()#s', 42 | fn(array $m): string => $m[1] . str_replace('loadHTML($html); 48 | } else { 49 | if (!preg_match('~' . $html; 51 | } 52 | $dom = Dom\HTMLDocument::createFromString($html, Dom\HTML_NO_DEFAULT_NS, 'UTF-8'); 53 | } 54 | 55 | $errors = libxml_get_errors(); 56 | libxml_use_internal_errors($old); 57 | 58 | foreach ($errors as $error) { 59 | if (!preg_match('#Tag \S+ invalid#', $error->message)) { 60 | trigger_error(__METHOD__ . ": $error->message on line $error->line.", E_USER_WARNING); 61 | } 62 | } 63 | 64 | return simplexml_import_dom($dom, self::class); 65 | } 66 | 67 | 68 | /** 69 | * Creates a DomQuery object from an XML string. 70 | */ 71 | public static function fromXml(string $xml): self 72 | { 73 | return simplexml_load_string($xml, self::class); 74 | } 75 | 76 | 77 | /** 78 | * Returns array of elements matching CSS selector. 79 | * @return DomQuery[] 80 | */ 81 | public function find(string $selector): array 82 | { 83 | if (PHP_VERSION_ID < 80400) { 84 | return str_starts_with($selector, ':scope') 85 | ? $this->xpath('self::' . self::css2xpath(substr($selector, 6))) 86 | : $this->xpath('descendant::' . self::css2xpath($selector)); 87 | } 88 | 89 | return array_map( 90 | fn($el) => simplexml_import_dom($el, self::class), 91 | iterator_to_array(Dom\import_simplexml($this)->querySelectorAll($selector)), 92 | ); 93 | } 94 | 95 | 96 | /** 97 | * Checks if any descendant matches CSS selector. 98 | */ 99 | public function has(string $selector): bool 100 | { 101 | return PHP_VERSION_ID < 80400 102 | ? (bool) $this->find($selector) 103 | : (bool) Dom\import_simplexml($this)->querySelector($selector); 104 | } 105 | 106 | 107 | /** 108 | * Checks if element matches CSS selector. 109 | */ 110 | public function matches(string $selector): bool 111 | { 112 | return PHP_VERSION_ID < 80400 113 | ? (bool) $this->xpath('self::' . self::css2xpath($selector)) 114 | : Dom\import_simplexml($this)->matches($selector); 115 | } 116 | 117 | 118 | /** 119 | * Returns closest ancestor matching CSS selector. 120 | */ 121 | public function closest(string $selector): ?self 122 | { 123 | if (PHP_VERSION_ID < 80400) { 124 | throw new \LogicException('Requires PHP 8.4 or newer.'); 125 | } 126 | $el = Dom\import_simplexml($this)->closest($selector); 127 | return $el ? simplexml_import_dom($el, self::class) : null; 128 | } 129 | 130 | 131 | /** 132 | * Converts a CSS selector into an XPath expression. 133 | */ 134 | public static function css2xpath(string $css): string 135 | { 136 | $xpath = '*'; 137 | preg_match_all(<<<'XX' 138 | / 139 | ([#.:]?)([a-z][a-z0-9_-]*)| # id, class, pseudoclass (1,2) 140 | \[ 141 | ([a-z0-9_-]+) 142 | (?: 143 | ([~*^$]?)=( 144 | "[^"]*"| 145 | '[^']*'| 146 | [^\]]+ 147 | ) 148 | )? 149 | \]| # [attr=val] (3,4,5) 150 | \s*([>,+~])\s*| # > , + ~ (6) 151 | (\s+)| # whitespace (7) 152 | (\*) # * (8) 153 | /ix 154 | XX, trim($css), $matches, PREG_SET_ORDER); 155 | foreach ($matches as $m) { 156 | if ($m[1] === '#') { // #ID 157 | $xpath .= "[@id='$m[2]']"; 158 | } elseif ($m[1] === '.') { // .class 159 | $xpath .= "[contains(concat(' ', normalize-space(@class), ' '), ' $m[2] ')]"; 160 | } elseif ($m[1] === ':') { // :pseudo-class 161 | throw new \InvalidArgumentException('Not implemented.'); 162 | } elseif ($m[2]) { // tag 163 | $xpath = rtrim($xpath, '*') . $m[2]; 164 | } elseif ($m[3]) { // [attribute] 165 | $attr = '@' . strtolower($m[3]); 166 | if (!isset($m[5])) { 167 | $xpath .= "[$attr]"; 168 | continue; 169 | } 170 | 171 | $val = trim($m[5], '"\''); 172 | if ($m[4] === '') { 173 | $xpath .= "[$attr='$val']"; 174 | } elseif ($m[4] === '~') { 175 | $xpath .= "[contains(concat(' ', normalize-space($attr), ' '), ' $val ')]"; 176 | } elseif ($m[4] === '*') { 177 | $xpath .= "[contains($attr, '$val')]"; 178 | } elseif ($m[4] === '^') { 179 | $xpath .= "[starts-with($attr, '$val')]"; 180 | } elseif ($m[4] === '$') { 181 | $xpath .= "[substring($attr, string-length($attr)-0)='$val']"; 182 | } 183 | } elseif ($m[6] === '>') { 184 | $xpath .= '/*'; 185 | } elseif ($m[6] === ',') { 186 | $xpath .= '|//*'; 187 | } elseif ($m[6] === '~') { 188 | $xpath .= '/following-sibling::*'; 189 | } elseif ($m[6] === '+') { 190 | throw new \InvalidArgumentException('Not implemented.'); 191 | } elseif ($m[7]) { 192 | $xpath .= '//*'; 193 | } 194 | } 195 | 196 | return $xpath; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Framework/FileMutator.php: -------------------------------------------------------------------------------- 1 | handle); 45 | } 46 | 47 | 48 | public function dir_opendir(string $path, int $options): bool 49 | { 50 | $this->handle = $this->context 51 | ? $this->native('opendir', $path, $this->context) 52 | : $this->native('opendir', $path); 53 | return (bool) $this->handle; 54 | } 55 | 56 | 57 | public function dir_readdir() 58 | { 59 | return readdir($this->handle); 60 | } 61 | 62 | 63 | public function dir_rewinddir(): bool 64 | { 65 | return (bool) rewinddir($this->handle); 66 | } 67 | 68 | 69 | public function mkdir(string $path, int $mode, int $options): bool 70 | { 71 | $recursive = (bool) ($options & STREAM_MKDIR_RECURSIVE); 72 | return $this->context 73 | ? $this->native('mkdir', $path, $mode, $recursive, $this->context) 74 | : $this->native('mkdir', $path, $mode, $recursive); 75 | } 76 | 77 | 78 | public function rename(string $pathFrom, string $pathTo): bool 79 | { 80 | return $this->context 81 | ? $this->native('rename', $pathFrom, $pathTo, $this->context) 82 | : $this->native('rename', $pathFrom, $pathTo); 83 | } 84 | 85 | 86 | public function rmdir(string $path, int $options): bool 87 | { 88 | return $this->context 89 | ? $this->native('rmdir', $path, $this->context) 90 | : $this->native('rmdir', $path); 91 | } 92 | 93 | 94 | public function stream_cast(int $castAs) 95 | { 96 | return $this->handle; 97 | } 98 | 99 | 100 | public function stream_close(): void 101 | { 102 | fclose($this->handle); 103 | } 104 | 105 | 106 | public function stream_eof(): bool 107 | { 108 | return feof($this->handle); 109 | } 110 | 111 | 112 | public function stream_flush(): bool 113 | { 114 | return fflush($this->handle); 115 | } 116 | 117 | 118 | public function stream_lock(int $operation): bool 119 | { 120 | return $operation 121 | ? flock($this->handle, $operation) 122 | : true; 123 | } 124 | 125 | 126 | public function stream_metadata(string $path, int $option, $value): bool 127 | { 128 | switch ($option) { 129 | case STREAM_META_TOUCH: 130 | return $this->native('touch', $path, $value[0] ?? time(), $value[1] ?? time()); 131 | case STREAM_META_OWNER_NAME: 132 | case STREAM_META_OWNER: 133 | return $this->native('chown', $path, $value); 134 | case STREAM_META_GROUP_NAME: 135 | case STREAM_META_GROUP: 136 | return $this->native('chgrp', $path, $value); 137 | case STREAM_META_ACCESS: 138 | return $this->native('chmod', $path, $value); 139 | } 140 | 141 | return false; 142 | } 143 | 144 | 145 | public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool 146 | { 147 | $usePath = (bool) ($options & STREAM_USE_PATH); 148 | if ($mode === 'rb' && pathinfo($path, PATHINFO_EXTENSION) === 'php') { 149 | $content = $this->native('file_get_contents', $path, $usePath, $this->context); 150 | if ($content === false) { 151 | return false; 152 | } else { 153 | foreach (self::$mutators as $mutator) { 154 | $content = $mutator($content); 155 | } 156 | 157 | $this->handle = tmpfile(); 158 | $this->native('fwrite', $this->handle, $content); 159 | $this->native('fseek', $this->handle, 0); 160 | return true; 161 | } 162 | } else { 163 | $this->handle = $this->context 164 | ? $this->native('fopen', $path, $mode, $usePath, $this->context) 165 | : $this->native('fopen', $path, $mode, $usePath); 166 | return (bool) $this->handle; 167 | } 168 | } 169 | 170 | 171 | public function stream_read(int $count) 172 | { 173 | return fread($this->handle, $count); 174 | } 175 | 176 | 177 | public function stream_seek(int $offset, int $whence = SEEK_SET): bool 178 | { 179 | return fseek($this->handle, $offset, $whence) === 0; 180 | } 181 | 182 | 183 | public function stream_set_option(int $option, int $arg1, int $arg2): bool 184 | { 185 | return false; 186 | } 187 | 188 | 189 | public function stream_stat() 190 | { 191 | return fstat($this->handle); 192 | } 193 | 194 | 195 | public function stream_tell(): int 196 | { 197 | return ftell($this->handle); 198 | } 199 | 200 | 201 | public function stream_truncate(int $newSize): bool 202 | { 203 | return ftruncate($this->handle, $newSize); 204 | } 205 | 206 | 207 | public function stream_write(string $data) 208 | { 209 | return fwrite($this->handle, $data); 210 | } 211 | 212 | 213 | public function unlink(string $path): bool 214 | { 215 | return $this->native('unlink', $path); 216 | } 217 | 218 | 219 | public function url_stat(string $path, int $flags) 220 | { 221 | $func = $flags & STREAM_URL_STAT_LINK ? 'lstat' : 'stat'; 222 | return $flags & STREAM_URL_STAT_QUIET 223 | ? @$this->native($func, $path) 224 | : $this->native($func, $path); 225 | } 226 | 227 | 228 | private function native(string $func) 229 | { 230 | stream_wrapper_restore(self::Protocol); 231 | try { 232 | return $func(...array_slice(func_get_args(), 1)); 233 | } finally { 234 | stream_wrapper_unregister(self::Protocol); 235 | stream_wrapper_register(self::Protocol, self::class); 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/Runner/Runner.php: -------------------------------------------------------------------------------- 1 | interpreter = $interpreter; 47 | $this->testHandler = new TestHandler($this); 48 | } 49 | 50 | 51 | public function setEnvironmentVariable(string $name, string $value): void 52 | { 53 | $this->envVars[$name] = $value; 54 | } 55 | 56 | 57 | public function getEnvironmentVariables(): array 58 | { 59 | return $this->envVars; 60 | } 61 | 62 | 63 | public function addPhpIniOption(string $name, ?string $value = null): void 64 | { 65 | $this->interpreter = $this->interpreter->withPhpIniOption($name, $value); 66 | } 67 | 68 | 69 | public function setTempDirectory(?string $path): void 70 | { 71 | $this->tempDir = $path; 72 | $this->testHandler->setTempDirectory($path); 73 | } 74 | 75 | 76 | /** 77 | * Runs all tests. 78 | */ 79 | public function run(): bool 80 | { 81 | $this->result = true; 82 | $this->interrupted = false; 83 | 84 | foreach ($this->outputHandlers as $handler) { 85 | $handler->begin(); 86 | } 87 | 88 | $this->jobs = $running = []; 89 | foreach ($this->paths as $path) { 90 | $this->findTests($path); 91 | } 92 | 93 | if ($this->tempDir) { 94 | usort( 95 | $this->jobs, 96 | fn(Job $a, Job $b): int => $this->getLastResult($a->getTest()) - $this->getLastResult($b->getTest()), 97 | ); 98 | } 99 | 100 | $threads = range(1, $this->threadCount); 101 | 102 | $async = $this->threadCount > 1 && count($this->jobs) > 1; 103 | 104 | try { 105 | while (($this->jobs || $running) && !$this->interrupted) { 106 | while ($threads && $this->jobs) { 107 | $running[] = $job = array_shift($this->jobs); 108 | $job->setEnvironmentVariable(Environment::VariableThread, (string) array_shift($threads)); 109 | $job->run(async: $async); 110 | } 111 | 112 | if ($async) { 113 | usleep(Job::RunSleep); // stream_select() doesn't work with proc_open() 114 | } 115 | 116 | foreach ($running as $key => $job) { 117 | if ($this->interrupted) { 118 | break 2; 119 | } 120 | 121 | if (!$job->isRunning()) { 122 | $threads[] = $job->getEnvironmentVariable(Environment::VariableThread); 123 | $this->testHandler->assess($job); 124 | unset($running[$key]); 125 | } 126 | } 127 | } 128 | } finally { 129 | foreach ($this->outputHandlers as $handler) { 130 | $handler->end(); 131 | } 132 | } 133 | 134 | return $this->result; 135 | } 136 | 137 | 138 | private function findTests(string $path): void 139 | { 140 | if (strpbrk($path, '*?') === false && !file_exists($path)) { 141 | throw new \InvalidArgumentException("File or directory '$path' not found."); 142 | } 143 | 144 | if (is_dir($path)) { 145 | foreach (glob(str_replace('[', '[[]', $path) . '/*', GLOB_ONLYDIR) ?: [] as $dir) { 146 | if (in_array(basename($dir), $this->ignoreDirs, true)) { 147 | continue; 148 | } 149 | 150 | $this->findTests($dir); 151 | } 152 | 153 | $this->findTests($path . '/*.phpt'); 154 | $this->findTests($path . '/*Test.php'); 155 | 156 | } else { 157 | foreach (glob(str_replace('[', '[[]', $path)) ?: [] as $file) { 158 | if (is_file($file)) { 159 | $this->testHandler->initiate(realpath($file)); 160 | } 161 | } 162 | } 163 | } 164 | 165 | 166 | /** 167 | * Appends new job to queue. 168 | */ 169 | public function addJob(Job $job): void 170 | { 171 | $this->jobs[] = $job; 172 | } 173 | 174 | 175 | public function prepareTest(Test $test): void 176 | { 177 | foreach ($this->outputHandlers as $handler) { 178 | $handler->prepare($test); 179 | } 180 | } 181 | 182 | 183 | /** 184 | * Writes to output handlers. 185 | */ 186 | public function finishTest(Test $test): void 187 | { 188 | $this->result = $this->result && ($test->getResult() !== Test::Failed); 189 | 190 | foreach ($this->outputHandlers as $handler) { 191 | $handler->finish($test); 192 | } 193 | 194 | if ($this->tempDir) { 195 | $lastResult = &$this->lastResults[$test->getSignature()]; 196 | if ($lastResult !== $test->getResult()) { 197 | file_put_contents($this->getLastResultFilename($test), $lastResult = $test->getResult()); 198 | } 199 | } 200 | 201 | if ($this->stopOnFail && $test->getResult() === Test::Failed) { 202 | $this->interrupted = true; 203 | } 204 | } 205 | 206 | 207 | public function getInterpreter(): PhpInterpreter 208 | { 209 | return $this->interpreter; 210 | } 211 | 212 | 213 | private function getLastResult(Test $test): int 214 | { 215 | $signature = $test->getSignature(); 216 | if (isset($this->lastResults[$signature])) { 217 | return $this->lastResults[$signature]; 218 | } 219 | 220 | $file = $this->getLastResultFilename($test); 221 | if (is_file($file)) { 222 | return $this->lastResults[$signature] = (int) file_get_contents($file); 223 | } 224 | 225 | return $this->lastResults[$signature] = Test::Prepared; 226 | } 227 | 228 | 229 | private function getLastResultFilename(Test $test): string 230 | { 231 | return $this->tempDir 232 | . DIRECTORY_SEPARATOR 233 | . pathinfo($test->getFile(), PATHINFO_FILENAME) 234 | . '.' 235 | . substr(md5($test->getSignature()), 0, 5) 236 | . '.result'; 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/Framework/HttpAssert.php: -------------------------------------------------------------------------------- 1 | $value) { 50 | if (is_int($key)) { 51 | $headerList[] = $value; 52 | } else { 53 | $headerList[] = "$key: $value"; 54 | } 55 | } 56 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headerList); 57 | } 58 | 59 | if ($body !== null) { 60 | curl_setopt($ch, CURLOPT_POSTFIELDS, $body); 61 | } 62 | 63 | if ($cookies) { 64 | $cookieString = ''; 65 | foreach ($cookies as $name => $value) { 66 | $cookieString .= "$name=$value; "; 67 | } 68 | curl_setopt($ch, CURLOPT_COOKIE, rtrim($cookieString, '; ')); 69 | } 70 | 71 | $response = curl_exec($ch); 72 | if ($response === false) { 73 | throw new \Exception('HTTP request failed: ' . curl_error($ch)); 74 | } 75 | 76 | $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); 77 | $res = new self( 78 | substr($response, $headerSize), 79 | curl_getinfo($ch, CURLINFO_HTTP_CODE), 80 | [], 81 | ); 82 | 83 | $headerString = substr($response, 0, $headerSize); 84 | foreach (explode("\r\n", $headerString) as $line) { 85 | if (str_contains($line, ':')) { 86 | [$name, $value] = explode(':', $line, 2); 87 | $res->headers[strtolower(trim($name))] = trim($value); 88 | } 89 | } 90 | 91 | return $res; 92 | } 93 | 94 | 95 | /** 96 | * Asserts HTTP response code matches expectation. 97 | */ 98 | public function expectCode(int|\Closure $expected): self 99 | { 100 | if ($expected instanceof \Closure) { 101 | Assert::true($expected($this->code), 'HTTP status code validation failed'); 102 | } else { 103 | Assert::same($expected, $this->code, 'HTTP status code validation failed'); 104 | } 105 | 106 | return $this; 107 | } 108 | 109 | 110 | /** 111 | * Asserts HTTP response code does not match expectation. 112 | */ 113 | public function denyCode(int|\Closure $expected): self 114 | { 115 | if ($expected instanceof \Closure) { 116 | Assert::false($expected($this->code), 'HTTP status code validation failed'); 117 | } else { 118 | Assert::notSame($expected, $this->code, 'HTTP status code validation failed'); 119 | } 120 | 121 | return $this; 122 | } 123 | 124 | 125 | /** 126 | * Asserts HTTP response header matches expectation. 127 | */ 128 | public function expectHeader( 129 | string $name, 130 | string|\Closure|null $expected = null, 131 | ?string $contains = null, 132 | ?string $matches = null, 133 | ): self 134 | { 135 | $headerValue = $this->headers[strtolower($name)] ?? null; 136 | if (!isset($headerValue)) { 137 | Assert::fail("Header '$name' should exist"); 138 | } elseif (is_string($expected)) { 139 | Assert::same($expected, $headerValue, "Header '$name' validation failed"); 140 | } elseif ($expected instanceof \Closure) { 141 | Assert::true($expected($headerValue), "Header '$name' validation failed"); 142 | } elseif ($contains !== null) { 143 | Assert::contains($contains, $headerValue, "Header '$name' validation failed"); 144 | } elseif ($matches !== null) { 145 | Assert::match($matches, $headerValue, "Header '$name' validation failed"); 146 | } 147 | 148 | return $this; 149 | } 150 | 151 | 152 | /** 153 | * Asserts HTTP response header does not match expectation. 154 | */ 155 | public function denyHeader( 156 | string $name, 157 | string|\Closure|null $expected = null, 158 | ?string $contains = null, 159 | ?string $matches = null, 160 | ): self 161 | { 162 | $headerValue = $this->headers[strtolower($name)] ?? null; 163 | if (!isset($headerValue)) { 164 | return $this; 165 | } 166 | 167 | if (is_string($expected)) { 168 | Assert::notSame($expected, $headerValue, "Header '$name' validation failed"); 169 | } elseif ($expected instanceof \Closure) { 170 | Assert::falsey($expected($headerValue), "Header '$name' validation failed"); 171 | } elseif ($contains !== null) { 172 | Assert::notContains($contains, $headerValue, "Header '$name' validation failed"); 173 | } elseif ($matches !== null) { 174 | Assert::notMatch($matches, $headerValue, "Header '$name' validation failed"); 175 | } else { 176 | Assert::fail("Header '$name' should not exist"); 177 | } 178 | 179 | return $this; 180 | } 181 | 182 | 183 | /** 184 | * Asserts HTTP response body matches expectation. 185 | */ 186 | public function expectBody( 187 | string|\Closure|null $expected = null, 188 | ?string $contains = null, 189 | ?string $matches = null, 190 | ): self 191 | { 192 | if (is_string($expected)) { 193 | Assert::same($expected, $this->body, 'Body validation failed'); 194 | } elseif ($expected instanceof \Closure) { 195 | Assert::true($expected($this->body), 'Body validation failed'); 196 | } elseif ($contains !== null) { 197 | Assert::contains($contains, $this->body, 'Body validation failed'); 198 | } elseif ($matches !== null) { 199 | Assert::match($matches, $this->body, 'Body validation failed'); 200 | } 201 | 202 | return $this; 203 | } 204 | 205 | 206 | /** 207 | * Asserts HTTP response body does not match expectation. 208 | */ 209 | public function denyBody( 210 | string|\Closure|null $expected = null, 211 | ?string $contains = null, 212 | ?string $matches = null, 213 | ): self 214 | { 215 | if (is_string($expected)) { 216 | Assert::notSame($expected, $this->body, 'Body validation failed'); 217 | } elseif ($expected instanceof \Closure) { 218 | Assert::falsey($expected($this->body), 'Body validation failed'); 219 | } elseif ($contains !== null) { 220 | Assert::notContains($contains, $this->body, 'Body validation failed'); 221 | } elseif ($matches !== null) { 222 | Assert::notMatch($matches, $this->body, 'Body validation failed'); 223 | } 224 | return $this; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/CodeCoverage/Generators/CloverXMLGenerator.php: -------------------------------------------------------------------------------- 1 | 'packages', 23 | 'fileCount' => 'files', 24 | 'linesOfCode' => 'loc', 25 | 'linesOfNonCommentedCode' => 'ncloc', 26 | 'classCount' => 'classes', 27 | 'methodCount' => 'methods', 28 | 'coveredMethodCount' => 'coveredmethods', 29 | 'statementCount' => 'statements', 30 | 'coveredStatementCount' => 'coveredstatements', 31 | 'elementCount' => 'elements', 32 | 'coveredElementCount' => 'coveredelements', 33 | 'conditionalCount' => 'conditionals', 34 | 'coveredConditionalCount' => 'coveredconditionals', 35 | ]; 36 | 37 | 38 | public function __construct(string $file, array $sources = []) 39 | { 40 | if (!extension_loaded('dom') || !extension_loaded('tokenizer')) { 41 | throw new \LogicException('CloverXML generator requires DOM and Tokenizer extensions to be loaded.'); 42 | } 43 | 44 | parent::__construct($file, $sources); 45 | } 46 | 47 | 48 | protected function renderSelf(): void 49 | { 50 | $time = (string) time(); 51 | $parser = new PhpParser; 52 | 53 | $doc = new DOMDocument; 54 | $doc->formatOutput = true; 55 | 56 | $elCoverage = $doc->appendChild($doc->createElement('coverage')); 57 | $elCoverage->setAttribute('generated', $time); 58 | 59 | // TODO: @name 60 | $elProject = $elCoverage->appendChild($doc->createElement('project')); 61 | $elProject->setAttribute('timestamp', $time); 62 | $elProjectMetrics = $elProject->appendChild($doc->createElement('metrics')); 63 | 64 | $projectMetrics = (object) [ 65 | 'packageCount' => 0, 66 | 'fileCount' => 0, 67 | 'linesOfCode' => 0, 68 | 'linesOfNonCommentedCode' => 0, 69 | 'classCount' => 0, 70 | 'methodCount' => 0, 71 | 'coveredMethodCount' => 0, 72 | 'statementCount' => 0, 73 | 'coveredStatementCount' => 0, 74 | 'elementCount' => 0, 75 | 'coveredElementCount' => 0, 76 | 'conditionalCount' => 0, 77 | 'coveredConditionalCount' => 0, 78 | ]; 79 | 80 | foreach ($this->getSourceIterator() as $file) { 81 | $file = (string) $file; 82 | 83 | $projectMetrics->fileCount++; 84 | 85 | if (empty($this->data[$file])) { 86 | $coverageData = null; 87 | $this->totalSum += count(file($file, FILE_SKIP_EMPTY_LINES)); 88 | } else { 89 | $coverageData = $this->data[$file]; 90 | } 91 | 92 | // TODO: split to by namespace? 93 | $elFile = $elProject->appendChild($doc->createElement('file')); 94 | $elFile->setAttribute('name', $file); 95 | $elFileMetrics = $elFile->appendChild($doc->createElement('metrics')); 96 | 97 | try { 98 | $code = $parser->parse(file_get_contents($file)); 99 | } catch (\ParseError $e) { 100 | throw new \ParseError($e->getMessage() . ' in file ' . $file); 101 | } 102 | 103 | $fileMetrics = (object) [ 104 | 'linesOfCode' => $code->linesOfCode, 105 | 'linesOfNonCommentedCode' => $code->linesOfCode - $code->linesOfComments, 106 | 'classCount' => count($code->classes) + count($code->traits), 107 | 'methodCount' => 0, 108 | 'coveredMethodCount' => 0, 109 | 'statementCount' => 0, 110 | 'coveredStatementCount' => 0, 111 | 'elementCount' => 0, 112 | 'coveredElementCount' => 0, 113 | 'conditionalCount' => 0, 114 | 'coveredConditionalCount' => 0, 115 | ]; 116 | 117 | foreach (array_merge($code->classes, $code->traits) as $name => $info) { // TODO: interfaces? 118 | $elClass = $elFile->appendChild($doc->createElement('class')); 119 | if (($tmp = strrpos($name, '\\')) === false) { 120 | $elClass->setAttribute('name', $name); 121 | } else { 122 | $elClass->setAttribute('namespace', substr($name, 0, $tmp)); 123 | $elClass->setAttribute('name', substr($name, $tmp + 1)); 124 | } 125 | 126 | $elClassMetrics = $elClass->appendChild($doc->createElement('metrics')); 127 | $classMetrics = $this->calculateClassMetrics($info, $coverageData); 128 | self::setMetricAttributes($elClassMetrics, $classMetrics); 129 | self::appendMetrics($fileMetrics, $classMetrics); 130 | } 131 | 132 | self::setMetricAttributes($elFileMetrics, $fileMetrics); 133 | 134 | 135 | foreach ((array) $coverageData as $line => $count) { 136 | if ($count === self::LineDead) { 137 | continue; 138 | } 139 | 140 | // Line type can be 'method' but Xdebug does not report such lines as executed. 141 | $elLine = $elFile->appendChild($doc->createElement('line')); 142 | $elLine->setAttribute('num', (string) $line); 143 | $elLine->setAttribute('type', 'stmt'); 144 | $elLine->setAttribute('count', (string) max(0, $count)); 145 | 146 | $this->totalSum++; 147 | $this->coveredSum += $count > 0 ? 1 : 0; 148 | } 149 | 150 | self::appendMetrics($projectMetrics, $fileMetrics); 151 | } 152 | 153 | // TODO: What about reported (covered) lines outside of class/trait definition? 154 | self::setMetricAttributes($elProjectMetrics, $projectMetrics); 155 | 156 | echo $doc->saveXML(); 157 | } 158 | 159 | 160 | private function calculateClassMetrics(\stdClass $info, ?array $coverageData = null): \stdClass 161 | { 162 | $stats = (object) [ 163 | 'methodCount' => count($info->methods), 164 | 'coveredMethodCount' => 0, 165 | 'statementCount' => 0, 166 | 'coveredStatementCount' => 0, 167 | 'conditionalCount' => 0, 168 | 'coveredConditionalCount' => 0, 169 | 'elementCount' => null, 170 | 'coveredElementCount' => null, 171 | ]; 172 | 173 | foreach ($info->methods as $name => $methodInfo) { 174 | [$lineCount, $coveredLineCount] = $this->analyzeMethod($methodInfo, $coverageData); 175 | 176 | $stats->statementCount += $lineCount; 177 | 178 | if ($coverageData !== null) { 179 | $stats->coveredMethodCount += $lineCount === $coveredLineCount ? 1 : 0; 180 | $stats->coveredStatementCount += $coveredLineCount; 181 | } 182 | } 183 | 184 | $stats->elementCount = $stats->methodCount + $stats->statementCount; 185 | $stats->coveredElementCount = $stats->coveredMethodCount + $stats->coveredStatementCount; 186 | 187 | return $stats; 188 | } 189 | 190 | 191 | private static function analyzeMethod(\stdClass $info, ?array $coverageData = null): array 192 | { 193 | $count = 0; 194 | $coveredCount = 0; 195 | 196 | if ($coverageData === null) { // Never loaded file 197 | $count = max(1, $info->end - $info->start - 2); 198 | } else { 199 | for ($i = $info->start; $i <= $info->end; $i++) { 200 | if (isset($coverageData[$i]) && $coverageData[$i] !== self::LineDead) { 201 | $count++; 202 | if ($coverageData[$i] > 0) { 203 | $coveredCount++; 204 | } 205 | } 206 | } 207 | } 208 | 209 | return [$count, $coveredCount]; 210 | } 211 | 212 | 213 | private static function appendMetrics(\stdClass $summary, \stdClass $add): void 214 | { 215 | foreach ($add as $name => $value) { 216 | $summary->{$name} += $value; 217 | } 218 | } 219 | 220 | 221 | private static function setMetricAttributes(DOMElement $element, \stdClass $metrics): void 222 | { 223 | foreach ($metrics as $name => $value) { 224 | $element->setAttribute(self::$metricAttributesMap[$name], (string) $value); 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/Framework/Environment.php: -------------------------------------------------------------------------------- 1 | self::$useColors ? $s : Dumper::removeColors($s), 99 | 1, 100 | PHP_OUTPUT_HANDLER_FLUSHABLE, 101 | ); 102 | } 103 | 104 | 105 | /** 106 | * Configures PHP error handling. 107 | */ 108 | public static function setupErrors(): void 109 | { 110 | error_reporting(E_ALL); 111 | ini_set('display_errors', '1'); 112 | ini_set('html_errors', '0'); 113 | ini_set('log_errors', '0'); 114 | 115 | set_exception_handler([self::class, 'handleException']); 116 | 117 | set_error_handler(function (int $severity, string $message, string $file, int $line): ?bool { 118 | if ( 119 | in_array($severity, [E_RECOVERABLE_ERROR, E_USER_ERROR], true) 120 | || ($severity & error_reporting()) === $severity 121 | ) { 122 | self::handleException(new \ErrorException($message, 0, $severity, $file, $line)); 123 | } 124 | 125 | return false; 126 | }); 127 | 128 | register_shutdown_function(function (): void { 129 | Assert::$onFailure = [self::class, 'handleException']; 130 | 131 | $error = error_get_last(); 132 | register_shutdown_function(function () use ($error): void { 133 | if (in_array($error['type'] ?? null, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) { 134 | if (($error['type'] & error_reporting()) !== $error['type']) { // show fatal errors hidden by @shutup 135 | self::print("\n" . Dumper::color('white/red', "Fatal error: $error[message] in $error[file] on line $error[line]")); 136 | } 137 | } elseif (self::$checkAssertions && !Assert::$counter) { 138 | self::print("\n" . Dumper::color('white/red', 'Error: This test forgets to execute an assertion.')); 139 | self::exit(Runner\Job::CodeFail); 140 | } elseif (!getenv(self::VariableRunner) && self::$exitCode !== Runner\Job::CodeSkip) { 141 | self::print("\n" . (self::$exitCode ? Dumper::color('white/red', 'FAILURE') : Dumper::color('white/green', 'OK'))); 142 | } 143 | }); 144 | }); 145 | } 146 | 147 | 148 | /** 149 | * Creates global functions test(), testException(), setUp() and tearDown(). 150 | */ 151 | public static function setupFunctions(): void 152 | { 153 | require __DIR__ . '/functions.php'; 154 | } 155 | 156 | 157 | /** 158 | * @internal 159 | */ 160 | public static function handleException(\Throwable $e): void 161 | { 162 | self::$checkAssertions = false; 163 | self::print(Dumper::dumpException($e)); 164 | self::exit($e instanceof AssertException ? Runner\Job::CodeFail : Runner\Job::CodeError); 165 | } 166 | 167 | 168 | /** 169 | * Skips this test. 170 | */ 171 | public static function skip(string $message = ''): void 172 | { 173 | self::$checkAssertions = false; 174 | self::print("\nSkipped:\n$message"); 175 | self::exit(Runner\Job::CodeSkip); 176 | } 177 | 178 | 179 | /** 180 | * Locks the parallel tests. 181 | * @param string $path lock store directory 182 | */ 183 | public static function lock(string $name = '', string $path = ''): void 184 | { 185 | static $locks; 186 | $file = "$path/lock-" . md5($name); 187 | if (!isset($locks[$file])) { 188 | flock($locks[$file] = fopen($file, 'w'), LOCK_EX); 189 | } 190 | } 191 | 192 | 193 | /** 194 | * Returns current test annotations. 195 | */ 196 | public static function getTestAnnotations(): array 197 | { 198 | $trace = debug_backtrace(); 199 | return ($file = $trace[count($trace) - 1]['file'] ?? null) 200 | ? Helpers::parseDocComment(file_get_contents($file)) + ['file' => $file] 201 | : []; 202 | } 203 | 204 | 205 | /** 206 | * Removes keyword final from source codes. 207 | */ 208 | public static function bypassFinals(): void 209 | { 210 | FileMutator::addMutator(function (string $code): string { 211 | if (str_contains($code, 'final')) { 212 | $tokens = \PhpToken::tokenize($code, TOKEN_PARSE); 213 | $code = ''; 214 | foreach ($tokens as $token) { 215 | $code .= $token->is(T_FINAL) ? '' : $token->text; 216 | } 217 | } 218 | 219 | return $code; 220 | }); 221 | } 222 | 223 | 224 | /** 225 | * Loads data according to the file annotation or specified by Tester\Runner\TestHandler::initiateDataProvider() 226 | */ 227 | public static function loadData(): array 228 | { 229 | if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--dataprovider=(.*)#Ai', '$1', $_SERVER['argv']))) { 230 | [$key, $file] = explode('|', reset($tmp), 2); 231 | $data = DataProvider::load($file); 232 | if (!array_key_exists($key, $data)) { 233 | throw new \Exception("Missing dataset '$key' from data provider '$file'."); 234 | } 235 | 236 | return $data[$key]; 237 | } 238 | 239 | $annotations = self::getTestAnnotations(); 240 | if (!isset($annotations['dataprovider'])) { 241 | throw new \Exception('Missing annotation @dataProvider.'); 242 | } 243 | 244 | $provider = (array) $annotations['dataprovider']; 245 | [$file, $query] = DataProvider::parseAnnotation($provider[0], $annotations['file']); 246 | 247 | $data = DataProvider::load($file, $query); 248 | if (!$data) { 249 | throw new \Exception("No datasets from data provider '$file'" . ($query ? " for query '$query'" : '') . '.'); 250 | } 251 | 252 | return reset($data); 253 | } 254 | 255 | 256 | public static function exit(int $code = 0): void 257 | { 258 | self::$exitCode = $code; 259 | exit($code); 260 | } 261 | 262 | 263 | /** @internal */ 264 | public static function print(string $s): void 265 | { 266 | $s = $s === '' || str_ends_with($s, "\n") ? $s : $s . "\n"; 267 | if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { 268 | fwrite(STDOUT, self::$useColors ? $s : Dumper::removeColors($s)); 269 | } else { 270 | echo $s; 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/Framework/TestCase.php: -------------------------------------------------------------------------------- 1 | $rm->getName(), (new \ReflectionObject($this))->getMethods()), 43 | )); 44 | 45 | if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--method=([\w-]+)$#Ai', '$1', $_SERVER['argv']))) { 46 | $method = reset($tmp); 47 | if ($method === self::ListMethods) { 48 | $this->sendMethodList($methods); 49 | return; 50 | } 51 | 52 | try { 53 | $this->runTest($method); 54 | } catch (TestCaseSkippedException $e) { 55 | Environment::skip($e->getMessage()); 56 | } 57 | } else { 58 | foreach ($methods as $method) { 59 | try { 60 | $this->runTest($method); 61 | Environment::print(Dumper::color('lime', '√') . " $method"); 62 | } catch (TestCaseSkippedException $e) { 63 | Environment::print("s $method {$e->getMessage()}"); 64 | } catch (\Throwable $e) { 65 | Environment::print(Dumper::color('red', '×') . " $method\n\n"); 66 | throw $e; 67 | } 68 | } 69 | } 70 | } 71 | 72 | 73 | /** 74 | * Executes a specified test method within this test case, handling data providers and errors. 75 | * @param ?array $args arguments provided for the test method, bypassing data provider if provided. 76 | */ 77 | public function runTest(string $method, ?array $args = null): void 78 | { 79 | if (!method_exists($this, $method)) { 80 | throw new TestCaseException("Method '$method' does not exist."); 81 | } elseif (!preg_match(self::MethodPattern, $method)) { 82 | throw new TestCaseException("Method '$method' is not a testing method."); 83 | } 84 | 85 | $method = new \ReflectionMethod($this, $method); 86 | if (!$method->isPublic()) { 87 | throw new TestCaseException("Method {$method->getName()} is not public. Make it public or rename it."); 88 | } 89 | 90 | $info = Helpers::parseDocComment((string) $method->getDocComment()) + ['throws' => null]; 91 | 92 | if ($info['throws'] === '') { 93 | throw new TestCaseException("Missing class name in @throws annotation for {$method->getName()}()."); 94 | } elseif (is_array($info['throws'])) { 95 | throw new TestCaseException("Annotation @throws for {$method->getName()}() can be specified only once."); 96 | } else { 97 | $throws = is_string($info['throws']) ? preg_split('#\s+#', $info['throws'], 2) : []; 98 | } 99 | 100 | $data = $args === null 101 | ? $this->prepareTestData($method, (array) ($info['dataprovider'] ?? [])) 102 | : [$args]; 103 | 104 | if ($this->prevErrorHandler === false) { 105 | $this->prevErrorHandler = set_error_handler(function (int $severity): ?bool { 106 | if ($this->handleErrors && ($severity & error_reporting()) === $severity) { 107 | $this->handleErrors = false; 108 | $this->silentTearDown(); 109 | } 110 | 111 | return $this->prevErrorHandler 112 | ? ($this->prevErrorHandler)(...func_get_args()) 113 | : false; 114 | }); 115 | } 116 | 117 | foreach ($data as $k => $params) { 118 | try { 119 | $this->setUp(); 120 | 121 | $this->handleErrors = true; 122 | $params = array_values($params); 123 | try { 124 | if ($info['throws']) { 125 | $e = Assert::error(function () use ($method, $params): void { 126 | [$this, $method->getName()](...$params); 127 | }, ...$throws); 128 | if ($e instanceof AssertException) { 129 | throw $e; 130 | } 131 | } else { 132 | [$this, $method->getName()](...$params); 133 | } 134 | } catch (\Throwable $e) { 135 | $this->handleErrors = false; 136 | $this->silentTearDown(); 137 | throw $e; 138 | } 139 | 140 | $this->handleErrors = false; 141 | 142 | $this->tearDown(); 143 | 144 | } catch (AssertException $e) { 145 | throw $e->setMessage(sprintf( 146 | '%s in %s(%s)%s', 147 | $e->origMessage, 148 | $method->getName(), 149 | substr(Dumper::toLine($params), 1, -1), 150 | is_string($k) ? (" (data set '" . explode('-', $k, 2)[1] . "')") : '', 151 | )); 152 | } 153 | } 154 | } 155 | 156 | 157 | protected function getData(string $provider) 158 | { 159 | if (!str_contains($provider, '.')) { 160 | return $this->$provider(); 161 | } else { 162 | $rc = new \ReflectionClass($this); 163 | [$file, $query] = DataProvider::parseAnnotation($provider, $rc->getFileName()); 164 | return DataProvider::load($file, $query); 165 | } 166 | } 167 | 168 | 169 | /** 170 | * Setup logic to be executed before each test method. Override in subclasses for specific behaviors. 171 | * @return void 172 | */ 173 | protected function setUp() 174 | { 175 | } 176 | 177 | 178 | /** 179 | * Teardown logic to be executed after each test method. Override in subclasses to release resources. 180 | * @return void 181 | */ 182 | protected function tearDown() 183 | { 184 | } 185 | 186 | 187 | /** 188 | * Executes the tearDown method and suppresses any errors, ensuring clean teardown in all cases. 189 | */ 190 | private function silentTearDown(): void 191 | { 192 | set_error_handler(fn() => null); 193 | try { 194 | $this->tearDown(); 195 | } catch (\Throwable $e) { 196 | } 197 | 198 | restore_error_handler(); 199 | } 200 | 201 | 202 | /** 203 | * Skips the current test, optionally providing a reason for skipping. 204 | */ 205 | protected function skip(string $message = ''): void 206 | { 207 | throw new TestCaseSkippedException($message); 208 | } 209 | 210 | 211 | /** 212 | * Outputs a list of all test methods in the current test case. Used for Runner. 213 | */ 214 | private function sendMethodList(array $methods): void 215 | { 216 | Environment::$checkAssertions = false; 217 | header('Content-Type: text/plain'); 218 | echo "\n"; 219 | echo 'TestCase:' . get_debug_type($this) . "\n"; 220 | echo 'Method:' . implode("\nMethod:", $methods) . "\n"; 221 | 222 | $dependentFiles = []; 223 | $reflections = [new \ReflectionObject($this)]; 224 | while (count($reflections)) { 225 | $rc = array_shift($reflections); 226 | $dependentFiles[$rc->getFileName()] = 1; 227 | 228 | if ($rpc = $rc->getParentClass()) { 229 | $reflections[] = $rpc; 230 | } 231 | 232 | foreach ($rc->getTraits() as $rt) { 233 | $reflections[] = $rt; 234 | } 235 | } 236 | 237 | echo 'Dependency:' . implode("\nDependency:", array_keys($dependentFiles)) . "\n"; 238 | } 239 | 240 | 241 | /** 242 | * Prepares test data from specified data providers or default method parameters if no provider is specified. 243 | */ 244 | private function prepareTestData(\ReflectionMethod $method, array $dataprovider): array 245 | { 246 | $data = $defaultParams = []; 247 | 248 | foreach ($method->getParameters() as $param) { 249 | $defaultParams[$param->getName()] = $param->isDefaultValueAvailable() 250 | ? $param->getDefaultValue() 251 | : null; 252 | } 253 | 254 | foreach ($dataprovider as $i => $provider) { 255 | $res = $this->getData($provider); 256 | if (!is_array($res) && !$res instanceof \Traversable) { 257 | throw new TestCaseException("Data provider $provider() doesn't return array or Traversable."); 258 | } 259 | 260 | foreach ($res as $k => $set) { 261 | if (!is_array($set)) { 262 | $type = get_debug_type($set); 263 | throw new TestCaseException("Data provider $provider() item '$k' must be an array, $type given."); 264 | } 265 | 266 | $data["$i-$k"] = is_string(key($set)) 267 | ? array_merge($defaultParams, $set) 268 | : $set; 269 | } 270 | } 271 | 272 | if (!$dataprovider) { 273 | if ($method->getNumberOfRequiredParameters()) { 274 | throw new TestCaseException("Method {$method->getName()}() has arguments, but @dataProvider is missing."); 275 | } 276 | 277 | $data[] = []; 278 | } 279 | 280 | return $data; 281 | } 282 | } 283 | 284 | /** 285 | * Errors specific to TestCase operations. 286 | */ 287 | class TestCaseException extends \Exception 288 | { 289 | } 290 | 291 | /** 292 | * Exception thrown when a test case or a test method is skipped. 293 | */ 294 | class TestCaseSkippedException extends \Exception 295 | { 296 | } 297 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Nette Tester](https://github.com/nette/tester/assets/194960/19423421-c7e9-4bcb-a8cc-167003de2c70)](https://tester.nette.org) 2 | 3 | [![Downloads this Month](https://img.shields.io/packagist/dm/nette/tester.svg)](https://packagist.org/packages/nette/tester) 4 | [![Tests](https://github.com/nette/tester/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/tester/actions) 5 | [![Latest Stable Version](https://poser.pugx.org/nette/tester/v/stable)](https://github.com/nette/tester/releases) 6 | [![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/tester/blob/master/license.md) 7 | 8 |   9 | 10 | Introduction 11 | ------------ 12 | 13 | Nette Tester is a productive and enjoyable unit testing framework. It's used by 14 | the [Nette Framework](https://nette.org) and is capable of testing any PHP code. 15 | 16 | Documentation is available on the [Nette Tester website](https://tester.nette.org). 17 | Read the [blog](https://blog.nette.org/category/tester/) for new information. 18 | 19 |   20 | 21 | [Support Tester](https://github.com/sponsors/dg) 22 | -------------------------------------------- 23 | 24 | Do you like Nette Tester? Are you looking forward to the new features? 25 | 26 | [![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg) 27 | 28 | Thank you! 29 | 30 |   31 | 32 | Installation 33 | ------------ 34 | 35 | The recommended way to install Nette Tester is through Composer: 36 | 37 | ``` 38 | composer require nette/tester --dev 39 | ``` 40 | 41 | Alternatively, you can download the [tester.phar](https://github.com/nette/tester/releases) file. 42 | 43 | Nette Tester 2.5 is compatible with PHP 8.0 to 8.5. Collecting and processing code coverage information depends on Xdebug or PCOV extension, or PHPDBG SAPI. 44 | 45 |   46 | 47 | Writing Tests 48 | ------------- 49 | 50 | Imagine that we are testing this simple class: 51 | 52 | ```php 53 | class Greeting 54 | { 55 | function say($name) 56 | { 57 | if (!$name) { 58 | throw new InvalidArgumentException('Invalid name.'); 59 | } 60 | return "Hello $name"; 61 | } 62 | } 63 | ``` 64 | 65 | So we create test file named `greeting.test.phpt`: 66 | 67 | ```php 68 | require 'src/bootstrap.php'; 69 | 70 | use Tester\Assert; 71 | 72 | $h = new Greeting; 73 | 74 | // use an assertion function to test say() 75 | Assert::same('Hello John', $h->say('John')); 76 | ``` 77 | 78 | Thats' all! 79 | 80 | Now we run tests from command-line using the `tester` command: 81 | 82 | ``` 83 | > tester 84 | _____ ___ ___ _____ ___ ___ 85 | |_ _/ __)( __/_ _/ __)| _ ) 86 | |_| \___ /___) |_| \___ |_|_\ v2.5 87 | 88 | PHP 8.2.0 | php -n | 8 threads 89 | . 90 | OK (1 tests, 0 skipped, 0.0 seconds) 91 | ``` 92 | 93 | Nette Tester prints dot for successful test, F for failed test 94 | and S when the test has been skipped. 95 | 96 |   97 | 98 | Assertions 99 | ---------- 100 | 101 | This table shows all assertions (class `Assert` means `Tester\Assert`): 102 | 103 | - `Assert::same($expected, $actual)` - Reports an error if $expected and $actual are not the same. 104 | - `Assert::notSame($expected, $actual)` - Reports an error if $expected and $actual are the same. 105 | - `Assert::equal($expected, $actual)` - Like same(), but identity of objects and the order of keys in the arrays are ignored. 106 | - `Assert::notEqual($expected, $actual)` - Like notSame(), but identity of objects and arrays order are ignored. 107 | - `Assert::contains($needle, array $haystack)` - Reports an error if $needle is not an element of $haystack. 108 | - `Assert::contains($needle, string $haystack)` - Reports an error if $needle is not a substring of $haystack. 109 | - `Assert::notContains($needle, array $haystack)` - Reports an error if $needle is an element of $haystack. 110 | - `Assert::notContains($needle, string $haystack)` - Reports an error if $needle is a substring of $haystack. 111 | - `Assert::true($value)` - Reports an error if $value is not true. 112 | - `Assert::false($value)` - Reports an error if $value is not false. 113 | - `Assert::truthy($value)` - Reports an error if $value is not truthy. 114 | - `Assert::falsey($value)` - Reports an error if $value is not falsey. 115 | - `Assert::null($value)` - Reports an error if $value is not null. 116 | - `Assert::nan($value)` - Reports an error if $value is not NAN. 117 | - `Assert::type($type, $value)` - Reports an error if the variable $value is not of PHP or class type $type. 118 | - `Assert::exception($closure, $class, $message = null, $code = null)` - Checks if the function throws exception. 119 | - `Assert::error($closure, $level, $message = null)` - Checks if the function $closure throws PHP warning/notice/error. 120 | - `Assert::noError($closure)` - Checks that the function $closure does not throw PHP warning/notice/error or exception. 121 | - `Assert::match($pattern, $value)` - Compares result using regular expression or mask. 122 | - `Assert::matchFile($file, $value)` - Compares result using regular expression or mask sorted in file. 123 | - `Assert::count($count, $value)` - Reports an error if number of items in $value is not $count. 124 | - `Assert::with($objectOrClass, $closure)` - Executes function that can access private and protected members of given object via $this. 125 | 126 | Testing exceptions: 127 | 128 | ```php 129 | Assert::exception(function () { 130 | $h = new Greeting; 131 | $h->say(null); 132 | }, InvalidArgumentException::class, 'Invalid name.'); 133 | ``` 134 | 135 | Testing PHP errors, warnings or notices: 136 | 137 | ```php 138 | Assert::error(function () { 139 | $h = new Greeting; 140 | echo $h->abc; 141 | }, E_NOTICE, 'Undefined property: Greeting::$abc'); 142 | ``` 143 | 144 | Testing private access methods: 145 | 146 | ```php 147 | $h = new Greeting; 148 | Assert::with($h, function () { 149 | // normalize() is internal private method. 150 | Assert::same('Hello David', $this->normalize('Hello david')); // $this is Greeting 151 | }); 152 | ``` 153 | 154 |   155 | 156 | Tips and features 157 | ----------------- 158 | 159 | Running unit tests manually is annoying, so let Nette Tester to watch your folder 160 | with code and automatically re-run tests whenever code is changed: 161 | 162 | ``` 163 | tester -w /my/source/codes 164 | ``` 165 | 166 | Running tests in parallel is very much faster and Nette Tester uses 8 threads as default. 167 | If you wish to run the tests in series use: 168 | 169 | ``` 170 | tester -j 1 171 | ``` 172 | 173 | How do you find code that is not yet tested? Use Code-Coverage Analysis. This feature 174 | requires you have installed [Xdebug](https://xdebug.org/) or [PCOV](https://github.com/krakjoe/pcov) 175 | extension, or you are using PHPDBG SAPI. This will generate nice HTML report in `coverage.html`. 176 | 177 | ``` 178 | tester . -c php.ini --coverage coverage.html --coverage-src /my/source/codes 179 | ``` 180 | 181 | We can load Nette Tester using Composer's autoloader. In this case 182 | it is important to setup Nette Tester environment: 183 | 184 | ```php 185 | require 'vendor/autoload.php'; 186 | 187 | Tester\Environment::setup(); 188 | ``` 189 | 190 | We can also test HTML pages. Let the [template engine](https://latte.nette.org) generate 191 | HTML code or download existing page to `$html` variable. We will check whether 192 | the page contains form fields for username and password. The syntax is the 193 | same as the CSS selectors: 194 | 195 | ```php 196 | $dom = Tester\DomQuery::fromHtml($html); 197 | 198 | Assert::true($dom->has('input[name="username"]')); 199 | Assert::true($dom->has('input[name="password"]')); 200 | ``` 201 | 202 | For more inspiration see how [Nette Tester tests itself](https://github.com/nette/tester/tree/master/tests). 203 | 204 |   205 | 206 | Running tests 207 | ------------- 208 | 209 | The command-line test runner can be invoked through the `tester` command (or `php tester.php`). Take a look 210 | at the command-line options: 211 | 212 | ``` 213 | > tester 214 | 215 | Usage: 216 | tester [options] [ | ]... 217 | 218 | Options: 219 | -p Specify PHP interpreter to run (default: php). 220 | -c Look for php.ini file (or look in directory) . 221 | -C Use system-wide php.ini. 222 | -l | --log Write log to file . 223 | -d ... Define INI entry 'key' with value 'val'. 224 | -s Show information about skipped tests. 225 | --stop-on-fail Stop execution upon the first failure. 226 | -j Run jobs in parallel (default: 8). 227 | -o 228 | Specify output format. 229 | -w | --watch Watch directory. 230 | -i | --info Show tests environment info and exit. 231 | --setup Script for runner setup. 232 | --temp Path to temporary directory. Default by sys_get_temp_dir(). 233 | --colors [1|0] Enable or disable colors. 234 | --coverage Generate code coverage report to file. 235 | --coverage-src Path to source code. 236 | -h | --help This help. 237 | ``` 238 | -------------------------------------------------------------------------------- /src/Runner/TestHandler.php: -------------------------------------------------------------------------------- 1 | runner = $runner; 33 | } 34 | 35 | 36 | public function setTempDirectory(?string $path): void 37 | { 38 | $this->tempDir = $path; 39 | } 40 | 41 | 42 | public function initiate(string $file): void 43 | { 44 | [$annotations, $title] = $this->getAnnotations($file); 45 | $php = $this->runner->getInterpreter(); 46 | 47 | $tests = [new Test($file, $title)]; 48 | foreach (get_class_methods($this) as $method) { 49 | if (!preg_match('#^initiate(.+)#', strtolower($method), $m) || !isset($annotations[$m[1]])) { 50 | continue; 51 | } 52 | 53 | foreach ((array) $annotations[$m[1]] as $value) { 54 | /** @var Test[] $prepared */ 55 | $prepared = []; 56 | foreach ($tests as $test) { 57 | $res = $this->$method($test, $value, $php); 58 | if ($res === null) { 59 | $prepared[] = $test; 60 | } else { 61 | foreach (is_array($res) ? $res : [$res] as $testVariety) { 62 | \assert($testVariety instanceof Test); 63 | if ($testVariety->hasResult()) { 64 | $this->runner->prepareTest($testVariety); 65 | $this->runner->finishTest($testVariety); 66 | } else { 67 | $prepared[] = $testVariety; 68 | } 69 | } 70 | } 71 | } 72 | 73 | $tests = $prepared; 74 | } 75 | } 76 | 77 | foreach ($tests as $test) { 78 | $this->runner->prepareTest($test); 79 | $job = new Job($test, $php, $this->runner->getEnvironmentVariables()); 80 | $job->setTempDirectory($this->tempDir); 81 | $this->runner->addJob($job); 82 | } 83 | } 84 | 85 | 86 | public function assess(Job $job): void 87 | { 88 | $test = $job->getTest(); 89 | $annotations = $this->getAnnotations($test->getFile())[0] += [ 90 | 'exitcode' => Job::CodeOk, 91 | 'httpcode' => self::HttpOk, 92 | ]; 93 | 94 | foreach (get_class_methods($this) as $method) { 95 | if (!preg_match('#^assess(.+)#', strtolower($method), $m) || !isset($annotations[$m[1]])) { 96 | continue; 97 | } 98 | 99 | foreach ((array) $annotations[$m[1]] as $arg) { 100 | /** @var Test|null $res */ 101 | if ($res = $this->$method($job, $arg)) { 102 | $this->runner->finishTest($res); 103 | return; 104 | } 105 | } 106 | } 107 | 108 | $this->runner->finishTest($test->withResult(Test::Passed, $test->message, $job->getDuration())); 109 | } 110 | 111 | 112 | private function initiateSkip(Test $test, string $message): Test 113 | { 114 | return $test->withResult(Test::Skipped, $message); 115 | } 116 | 117 | 118 | private function initiatePhpVersion(Test $test, string $version, PhpInterpreter $interpreter): ?Test 119 | { 120 | if (preg_match('#^(<=|<|==|=|!=|<>|>=|>)?\s*(.+)#', $version, $matches) 121 | && version_compare($matches[2], $interpreter->getVersion(), $matches[1] ?: '>=')) { 122 | return $test->withResult(Test::Skipped, "Requires PHP $version."); 123 | } 124 | 125 | return null; 126 | } 127 | 128 | 129 | private function initiatePhpExtension(Test $test, string $value, PhpInterpreter $interpreter): ?Test 130 | { 131 | foreach (preg_split('#[\s,]+#', $value) as $extension) { 132 | if (!$interpreter->hasExtension($extension)) { 133 | return $test->withResult(Test::Skipped, "Requires PHP extension $extension."); 134 | } 135 | } 136 | 137 | return null; 138 | } 139 | 140 | 141 | private function initiatePhpIni(Test $test, string $pair, PhpInterpreter &$interpreter): void 142 | { 143 | [$name, $value] = explode('=', $pair, 2) + [1 => null]; 144 | $interpreter = $interpreter->withPhpIniOption($name, $value); 145 | } 146 | 147 | 148 | private function initiateDataProvider(Test $test, string $provider): array|Test 149 | { 150 | try { 151 | [$dataFile, $query, $optional] = Tester\DataProvider::parseAnnotation($provider, $test->getFile()); 152 | $data = Tester\DataProvider::load($dataFile, $query); 153 | if (count($data) < 1) { 154 | throw new \Exception("No records in data provider file '{$test->getFile()}'" . ($query ? " for query '$query'" : '') . '.'); 155 | } 156 | } catch (\Throwable $e) { 157 | return $test->withResult(empty($optional) ? Test::Failed : Test::Skipped, $e->getMessage()); 158 | } 159 | 160 | return array_map( 161 | fn(string $item): Test => $test->withArguments(['dataprovider' => "$item|$dataFile"]), 162 | array_keys($data), 163 | ); 164 | } 165 | 166 | 167 | private function initiateMultiple(Test $test, string $count): array 168 | { 169 | return array_map( 170 | fn(int $i): Test => $test->withArguments(['multiple' => $i]), 171 | range(0, (int) $count - 1), 172 | ); 173 | } 174 | 175 | 176 | private function initiateTestCase(Test $test, $foo, PhpInterpreter $interpreter) 177 | { 178 | $methods = null; 179 | 180 | if ($this->tempDir) { 181 | $cacheFile = $this->tempDir . DIRECTORY_SEPARATOR . 'TestHandler.testCase.' . md5($test->getSignature()) . '.list'; 182 | if (is_file($cacheFile)) { 183 | $cache = unserialize(file_get_contents($cacheFile)); 184 | 185 | $valid = true; 186 | foreach ($cache['files'] as $path => $mTime) { 187 | if (!is_file($path) || filemtime($path) !== $mTime) { 188 | $valid = false; 189 | break; 190 | } 191 | } 192 | 193 | if ($valid) { 194 | $methods = $cache['methods']; 195 | } 196 | } 197 | } 198 | 199 | if ($methods === null) { 200 | $job = new Job($test->withArguments(['method' => TestCase::ListMethods]), $interpreter, $this->runner->getEnvironmentVariables()); 201 | $job->setTempDirectory($this->tempDir); 202 | $job->run(); 203 | 204 | if (in_array($job->getExitCode(), [Job::CodeError, Job::CodeFail, Job::CodeSkip], true)) { 205 | return $test->withResult($job->getExitCode() === Job::CodeSkip ? Test::Skipped : Test::Failed, $job->getTest()->getOutput()); 206 | } 207 | 208 | $stdout = $job->getTest()->stdout; 209 | 210 | if (!preg_match('#^TestCase:([^\n]+)$#m', $stdout, $m)) { 211 | return $test->withResult(Test::Failed, "Cannot list TestCase methods in file '{$test->getFile()}'. Do you call TestCase::run() in it?"); 212 | } 213 | 214 | $testCaseClass = $m[1]; 215 | 216 | preg_match_all('#^Method:([^\n]+)$#m', $stdout, $m); 217 | if (count($m[1]) < 1) { 218 | return $test->withResult(Test::Skipped, "Class $testCaseClass in file '{$test->getFile()}' does not contain test methods."); 219 | } 220 | 221 | $methods = $m[1]; 222 | 223 | if ($this->tempDir) { 224 | preg_match_all('#^Dependency:([^\n]+)$#m', $stdout, $m); 225 | file_put_contents($cacheFile, serialize([ 226 | 'methods' => $methods, 227 | 'files' => array_combine($m[1], array_map('filemtime', $m[1])), 228 | ])); 229 | } 230 | } 231 | 232 | return array_map( 233 | fn(string $method): Test => $test 234 | ->withTitle(trim("$test->title $method")) 235 | ->withArguments(['method' => $method]), 236 | $methods, 237 | ); 238 | } 239 | 240 | 241 | private function assessExitCode(Job $job, string|int $code): ?Test 242 | { 243 | $code = (int) $code; 244 | if ($job->getExitCode() === Job::CodeSkip) { 245 | $message = preg_match('#.*Skipped:\n(.*?)$#Ds', $output = $job->getTest()->stdout, $m) 246 | ? $m[1] 247 | : $output; 248 | return $job->getTest()->withResult(Test::Skipped, trim($message)); 249 | 250 | } elseif ($job->getExitCode() !== $code) { 251 | $message = $job->getExitCode() !== Job::CodeFail 252 | ? "Exited with error code {$job->getExitCode()} (expected $code)" 253 | : ''; 254 | return $job->getTest()->withResult(Test::Failed, trim($message . "\n" . $job->getTest()->getOutput())); 255 | } 256 | 257 | return null; 258 | } 259 | 260 | 261 | private function assessHttpCode(Job $job, string|int $code): ?Test 262 | { 263 | if (!$this->runner->getInterpreter()->isCgi()) { 264 | return null; 265 | } 266 | 267 | $headers = $job->getHeaders(); 268 | $actual = (int) ($headers['Status'] ?? self::HttpOk); 269 | $code = (int) $code; 270 | return $code && $code !== $actual 271 | ? $job->getTest()->withResult(Test::Failed, "Exited with HTTP code $actual (expected $code)") 272 | : null; 273 | } 274 | 275 | 276 | private function assessOutputMatchFile(Job $job, string $file): ?Test 277 | { 278 | $file = dirname($job->getTest()->getFile()) . DIRECTORY_SEPARATOR . $file; 279 | if (!is_file($file)) { 280 | return $job->getTest()->withResult(Test::Failed, "Missing matching file '$file'."); 281 | } 282 | 283 | return $this->assessOutputMatch($job, file_get_contents($file)); 284 | } 285 | 286 | 287 | private function assessOutputMatch(Job $job, string $content): ?Test 288 | { 289 | $actual = $job->getTest()->stdout; 290 | if (!Tester\Assert::isMatching($content, $actual)) { 291 | [$content, $actual] = Tester\Assert::expandMatchingPatterns($content, $actual); 292 | Dumper::saveOutput($job->getTest()->getFile(), $actual, '.actual'); 293 | Dumper::saveOutput($job->getTest()->getFile(), $content, '.expected'); 294 | return $job->getTest()->withResult(Test::Failed, 'Failed: output should match ' . Dumper::toLine($content)); 295 | } 296 | 297 | return null; 298 | } 299 | 300 | 301 | private function getAnnotations(string $file): array 302 | { 303 | $annotations = Helpers::parseDocComment(file_get_contents($file)); 304 | $testTitle = isset($annotations[0]) 305 | ? preg_replace('#^TEST:\s*#i', '', $annotations[0]) 306 | : null; 307 | return [$annotations, $testTitle]; 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/Runner/CliTester.php: -------------------------------------------------------------------------------- 1 | setupErrors(); 35 | 36 | ob_start(); 37 | $cmd = $this->loadOptions(); 38 | 39 | $this->debugMode = (bool) $this->options['--debug']; 40 | if (isset($this->options['--colors'])) { 41 | Environment::$useColors = (bool) $this->options['--colors']; 42 | } elseif (in_array($this->stdoutFormat, ['tap', 'junit'], true)) { 43 | Environment::$useColors = false; 44 | } 45 | 46 | if ($cmd->isEmpty() || $this->options['--help']) { 47 | $cmd->help(); 48 | return 0; 49 | } 50 | 51 | $this->createPhpInterpreter(); 52 | 53 | if ($this->options['--info']) { 54 | $job = new Job(new Test(__DIR__ . '/info.php'), $this->interpreter); 55 | $job->setTempDirectory($this->options['--temp']); 56 | $job->run(); 57 | echo $job->getTest()->stdout; 58 | return 0; 59 | } 60 | 61 | $runner = $this->createRunner(); 62 | $runner->setEnvironmentVariable(Environment::VariableRunner, '1'); 63 | $runner->setEnvironmentVariable(Environment::VariableColors, (string) (int) Environment::$useColors); 64 | 65 | $this->installInterruptHandler(); 66 | 67 | if ($this->options['--coverage']) { 68 | $coverageFile = $this->prepareCodeCoverage($runner); 69 | } 70 | 71 | if ($this->stdoutFormat !== null) { 72 | ob_clean(); 73 | } 74 | 75 | ob_end_flush(); 76 | 77 | if ($this->options['--watch']) { 78 | $this->watch($runner); 79 | return 0; 80 | } 81 | 82 | $result = $runner->run(); 83 | 84 | if (isset($coverageFile) && preg_match('#\.(?:html?|xml)$#D', $coverageFile)) { 85 | $this->finishCodeCoverage($coverageFile); 86 | } 87 | 88 | return $result ? 0 : 1; 89 | } 90 | 91 | 92 | private function loadOptions(): CommandLine 93 | { 94 | $outputFiles = []; 95 | 96 | echo <<<'XX' 97 | _____ ___ ___ _____ ___ ___ 98 | |_ _/ __)( __/_ _/ __)| _ ) 99 | |_| \___ /___) |_| \___ |_|_\ v2.5.7 100 | 101 | 102 | XX; 103 | 104 | $cmd = new CommandLine( 105 | <<<'XX' 106 | Usage: 107 | tester [options] [ | ]... 108 | 109 | Options: 110 | -p Specify PHP interpreter to run (default: php). 111 | -c Look for php.ini file (or look in directory) . 112 | -C Use system-wide php.ini. 113 | -d ... Define INI entry 'key' with value 'value'. 114 | -s Show information about skipped tests. 115 | --stop-on-fail Stop execution upon the first failure. 116 | -j Run jobs in parallel (default: 8). 117 | -o (e.g. -o junit:output.xml) 118 | Specify one or more output formats with optional file name. 119 | -w | --watch Watch directory. 120 | -i | --info Show tests environment info and exit. 121 | --setup Script for runner setup. 122 | --temp Path to temporary directory. Default by sys_get_temp_dir(). 123 | --colors [1|0] Enable or disable colors. 124 | --coverage Generate code coverage report to file. 125 | --coverage-src Path to source code. 126 | -h | --help This help. 127 | 128 | XX, 129 | [ 130 | '-c' => [CommandLine::RealPath => true], 131 | '--watch' => [CommandLine::Repeatable => true, CommandLine::RealPath => true], 132 | '--setup' => [CommandLine::RealPath => true], 133 | '--temp' => [], 134 | 'paths' => [CommandLine::Repeatable => true, CommandLine::Value => getcwd()], 135 | '--debug' => [], 136 | '--cider' => [], 137 | '--coverage-src' => [CommandLine::RealPath => true, CommandLine::Repeatable => true], 138 | '-o' => [CommandLine::Repeatable => true, CommandLine::Normalizer => function ($arg) use (&$outputFiles) { 139 | [$format, $file] = explode(':', $arg, 2) + [1 => '']; 140 | 141 | if (isset($outputFiles[$file])) { 142 | throw new \Exception( 143 | $file === '' 144 | ? 'Option -o without file name parameter can be used only once.' 145 | : "Cannot specify output by -o into file '$file' more then once.", 146 | ); 147 | } elseif ($file === '') { 148 | $this->stdoutFormat = $format; 149 | } 150 | 151 | $outputFiles[$file] = true; 152 | 153 | return [$format, $file]; 154 | }], 155 | ], 156 | ); 157 | 158 | if (isset($_SERVER['argv'])) { 159 | if (($tmp = array_search('-l', $_SERVER['argv'], strict: true)) 160 | || ($tmp = array_search('-log', $_SERVER['argv'], strict: true)) 161 | || ($tmp = array_search('--log', $_SERVER['argv'], strict: true)) 162 | ) { 163 | $_SERVER['argv'][$tmp] = '-o'; 164 | $_SERVER['argv'][$tmp + 1] = 'log:' . $_SERVER['argv'][$tmp + 1]; 165 | } 166 | 167 | if ($tmp = array_search('--tap', $_SERVER['argv'], strict: true)) { 168 | unset($_SERVER['argv'][$tmp]); 169 | $_SERVER['argv'] = array_merge($_SERVER['argv'], ['-o', 'tap']); 170 | } 171 | } 172 | 173 | $this->options = $cmd->parse(); 174 | if ($this->options['--temp'] === null) { 175 | if (($temp = sys_get_temp_dir()) === '') { 176 | echo "Note: System temporary directory is not set.\n"; 177 | } elseif (($real = realpath($temp)) === false) { 178 | echo "Note: System temporary directory '$temp' does not exist.\n"; 179 | } else { 180 | $this->options['--temp'] = Helpers::prepareTempDir($real); 181 | } 182 | } else { 183 | $this->options['--temp'] = Helpers::prepareTempDir($this->options['--temp']); 184 | } 185 | 186 | return $cmd; 187 | } 188 | 189 | 190 | private function createPhpInterpreter(): void 191 | { 192 | $args = $this->options['-C'] ? [] : ['-n']; 193 | if ($this->options['-c']) { 194 | array_push($args, '-c', $this->options['-c']); 195 | } elseif (!$this->options['--info'] && !$this->options['-C']) { 196 | echo "Note: No php.ini is used.\n"; 197 | } 198 | 199 | if (in_array($this->stdoutFormat, ['tap', 'junit'], true)) { 200 | array_push($args, '-d', 'html_errors=off'); 201 | } 202 | 203 | foreach ($this->options['-d'] as $item) { 204 | array_push($args, '-d', $item); 205 | } 206 | 207 | $this->interpreter = new PhpInterpreter($this->options['-p'], $args); 208 | 209 | if ($error = $this->interpreter->getStartupError()) { 210 | echo Dumper::color('red', "PHP startup error: $error") . "\n"; 211 | } 212 | } 213 | 214 | 215 | private function createRunner(): Runner 216 | { 217 | $runner = new Runner($this->interpreter); 218 | $runner->paths = $this->options['paths']; 219 | $runner->threadCount = max(1, (int) $this->options['-j']); 220 | $runner->stopOnFail = (bool) $this->options['--stop-on-fail']; 221 | $runner->setTempDirectory($this->options['--temp']); 222 | 223 | if ($this->stdoutFormat === null) { 224 | $runner->outputHandlers[] = new Output\ConsolePrinter( 225 | $runner, 226 | (bool) $this->options['-s'], 227 | 'php://output', 228 | (bool) $this->options['--cider'], 229 | ); 230 | } 231 | 232 | foreach ($this->options['-o'] as $output) { 233 | [$format, $file] = $output; 234 | match ($format) { 235 | 'console', 'console-lines' => $runner->outputHandlers[] = new Output\ConsolePrinter( 236 | $runner, 237 | (bool) $this->options['-s'], 238 | $file, 239 | (bool) $this->options['--cider'], 240 | $format === 'console-lines', 241 | ), 242 | 'tap' => $runner->outputHandlers[] = new Output\TapPrinter($file), 243 | 'junit' => $runner->outputHandlers[] = new Output\JUnitPrinter($file), 244 | 'log' => $runner->outputHandlers[] = new Output\Logger($runner, $file), 245 | 'none' => null, 246 | default => throw new \LogicException("Undefined output printer '$format'.'"), 247 | }; 248 | } 249 | 250 | if ($this->options['--setup']) { 251 | (function () use ($runner): void { 252 | require func_get_arg(0); 253 | })($this->options['--setup']); 254 | } 255 | 256 | return $runner; 257 | } 258 | 259 | 260 | private function prepareCodeCoverage(Runner $runner): string 261 | { 262 | $engines = $this->interpreter->getCodeCoverageEngines(); 263 | if (count($engines) < 1) { 264 | throw new \Exception("Code coverage functionality requires Xdebug or PCOV extension or PHPDBG SAPI (used {$this->interpreter->getCommandLine()})"); 265 | } 266 | 267 | file_put_contents($this->options['--coverage'], ''); 268 | $file = realpath($this->options['--coverage']); 269 | 270 | [$engine, $version] = reset($engines); 271 | 272 | $runner->setEnvironmentVariable(Environment::VariableCoverage, $file); 273 | $runner->setEnvironmentVariable(Environment::VariableCoverageEngine, $engine); 274 | 275 | if ($engine === CodeCoverage\Collector::EngineXdebug && version_compare($version, '3.0.0', '>=')) { 276 | $runner->addPhpIniOption('xdebug.mode', ltrim(ini_get('xdebug.mode') . ',coverage', ',')); 277 | } 278 | 279 | if ($engine === CodeCoverage\Collector::EnginePcov && count($this->options['--coverage-src'])) { 280 | $runner->addPhpIniOption('pcov.directory', Helpers::findCommonDirectory($this->options['--coverage-src'])); 281 | } 282 | 283 | echo "Code coverage by $engine: $file\n"; 284 | return $file; 285 | } 286 | 287 | 288 | private function finishCodeCoverage(string $file): void 289 | { 290 | if (!in_array($this->stdoutFormat, ['none', 'tap', 'junit'], true)) { 291 | echo 'Generating code coverage report... '; 292 | } 293 | 294 | if (filesize($file) === 0) { 295 | echo 'failed. Coverage file is empty. Do you call Tester\Environment::setup() in tests?' . "\n"; 296 | return; 297 | } 298 | 299 | $generator = pathinfo($file, PATHINFO_EXTENSION) === 'xml' 300 | ? new CodeCoverage\Generators\CloverXMLGenerator($file, $this->options['--coverage-src']) 301 | : new CodeCoverage\Generators\HtmlGenerator($file, $this->options['--coverage-src']); 302 | $generator->render($file); 303 | echo round($generator->getCoveredPercent()) . "% covered\n"; 304 | } 305 | 306 | 307 | private function watch(Runner $runner): void 308 | { 309 | $prev = []; 310 | $counter = 0; 311 | while (true) { 312 | $state = []; 313 | foreach ($this->options['--watch'] as $directory) { 314 | foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) as $file) { 315 | if (substr($file->getExtension(), 0, 3) === 'php' && substr($file->getBasename(), 0, 1) !== '.') { 316 | $state[(string) $file] = @filemtime((string) $file); // @ file could be deleted in the meantime 317 | } 318 | } 319 | } 320 | 321 | if ($state !== $prev) { 322 | $prev = $state; 323 | try { 324 | $runner->run(); 325 | } catch (\ErrorException $e) { 326 | $this->displayException($e); 327 | } 328 | 329 | echo "\n"; 330 | $time = time(); 331 | } 332 | 333 | $idle = time() - $time; 334 | if ($idle >= 60 * 60) { 335 | $idle = 'long time'; 336 | } elseif ($idle >= 60) { 337 | $idle = round($idle / 60) . ' min'; 338 | } else { 339 | $idle .= ' sec'; 340 | } 341 | 342 | echo 'Watching ' . implode(', ', $this->options['--watch']) . " (idle for $idle) " . str_repeat('.', ++$counter % 5) . " \r"; 343 | sleep(2); 344 | } 345 | } 346 | 347 | 348 | private function setupErrors(): void 349 | { 350 | error_reporting(E_ALL); 351 | ini_set('html_errors', '0'); 352 | 353 | set_error_handler(function (int $severity, string $message, string $file, int $line) { 354 | if (($severity & error_reporting()) === $severity) { 355 | throw new \ErrorException($message, 0, $severity, $file, $line); 356 | } 357 | 358 | return false; 359 | }); 360 | 361 | set_exception_handler(function (\Throwable $e) { 362 | if (!$e instanceof InterruptException) { 363 | $this->displayException($e); 364 | } 365 | 366 | exit(2); 367 | }); 368 | } 369 | 370 | 371 | private function displayException(\Throwable $e): void 372 | { 373 | echo "\n"; 374 | echo $this->debugMode 375 | ? Dumper::dumpException($e) 376 | : Dumper::color('white/red', 'Error: ' . $e->getMessage()); 377 | echo "\n"; 378 | } 379 | 380 | 381 | private function installInterruptHandler(): void 382 | { 383 | if (function_exists('pcntl_signal')) { 384 | pcntl_signal(SIGINT, function (): void { 385 | pcntl_signal(SIGINT, SIG_DFL); 386 | throw new InterruptException; 387 | }); 388 | pcntl_async_signals(true); 389 | 390 | } elseif (function_exists('sapi_windows_set_ctrl_handler') && PHP_SAPI === 'cli') { 391 | sapi_windows_set_ctrl_handler(function (): void { 392 | throw new InterruptException; 393 | }); 394 | } 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /src/Framework/Dumper.php: -------------------------------------------------------------------------------- 1 | self::$maxLength) { 50 | $var = substr($var, 0, self::$maxLength) . '...'; 51 | } 52 | 53 | return self::encodeStringLine($var); 54 | 55 | } elseif (is_array($var)) { 56 | $out = ''; 57 | $counter = 0; 58 | foreach ($var as $k => &$v) { 59 | $out .= ($out === '' ? '' : ', '); 60 | if (strlen($out) > self::$maxLength) { 61 | $out .= '...'; 62 | break; 63 | } 64 | 65 | $out .= ($k === $counter ? '' : self::toLine($k) . ' => ') 66 | . (is_array($v) && $v ? '[...]' : self::toLine($v)); 67 | $counter = is_int($k) ? max($k + 1, $counter) : $counter; 68 | } 69 | 70 | return "[$out]"; 71 | 72 | } elseif ($var instanceof \Throwable) { 73 | return 'Exception ' . $var::class . ': ' . ($var->getCode() ? '#' . $var->getCode() . ' ' : '') . $var->getMessage(); 74 | 75 | } elseif ($var instanceof Expect) { 76 | return $var->dump(); 77 | 78 | } elseif (is_object($var)) { 79 | return self::objectToLine($var); 80 | 81 | } elseif (is_resource($var)) { 82 | return 'resource(' . get_resource_type($var) . ')'; 83 | 84 | } else { 85 | return 'unknown type'; 86 | } 87 | } 88 | 89 | 90 | /** 91 | * Formats object to line. 92 | */ 93 | private static function objectToLine(object $object): string 94 | { 95 | $line = $object::class; 96 | if ($object instanceof \DateTime || $object instanceof \DateTimeInterface) { 97 | $line .= '(' . $object->format('Y-m-d H:i:s O') . ')'; 98 | } 99 | 100 | return $line . '(' . self::hash($object) . ')'; 101 | } 102 | 103 | 104 | /** 105 | * Dumps variable in PHP format. 106 | */ 107 | public static function toPhp(mixed $var): string 108 | { 109 | return self::_toPhp($var); 110 | } 111 | 112 | 113 | /** 114 | * Returns object's stripped hash. 115 | */ 116 | private static function hash(object $object): string 117 | { 118 | return '#' . substr(md5(spl_object_hash($object)), 0, 4); 119 | } 120 | 121 | 122 | private static function _toPhp(mixed &$var, array &$list = [], int $level = 0, int &$line = 1): string 123 | { 124 | if (is_float($var)) { 125 | $var = str_replace(',', '.', "$var"); 126 | return !str_contains($var, '.') ? $var . '.0' : $var; 127 | 128 | } elseif (is_bool($var)) { 129 | return $var ? 'true' : 'false'; 130 | 131 | } elseif ($var === null) { 132 | return 'null'; 133 | 134 | } elseif (is_string($var)) { 135 | $res = self::encodeStringPhp($var); 136 | $line += substr_count($res, "\n"); 137 | return $res; 138 | 139 | } elseif (is_array($var)) { 140 | $space = str_repeat("\t", $level); 141 | 142 | static $marker; 143 | if ($marker === null) { 144 | $marker = uniqid("\x00", more_entropy: true); 145 | } 146 | 147 | if (empty($var)) { 148 | $out = ''; 149 | 150 | } elseif ($level > self::$maxDepth || isset($var[$marker])) { 151 | return '/* Nesting level too deep or recursive dependency */'; 152 | 153 | } else { 154 | $out = "\n$space"; 155 | $outShort = ''; 156 | $var[$marker] = true; 157 | $oldLine = $line; 158 | $line++; 159 | $counter = 0; 160 | foreach ($var as $k => &$v) { 161 | if ($k !== $marker) { 162 | $item = ($k === $counter ? '' : self::_toPhp($k, $list, $level + 1, $line) . ' => ') . self::_toPhp($v, $list, $level + 1, $line); 163 | $counter = is_int($k) ? max($k + 1, $counter) : $counter; 164 | $outShort .= ($outShort === '' ? '' : ', ') . $item; 165 | $out .= "\t$item,\n$space"; 166 | $line++; 167 | } 168 | } 169 | 170 | unset($var[$marker]); 171 | if (!str_contains($outShort, "\n") && strlen($outShort) < self::$maxLength) { 172 | $line = $oldLine; 173 | $out = $outShort; 174 | } 175 | } 176 | 177 | return '[' . $out . ']'; 178 | 179 | } elseif ($var instanceof \Closure) { 180 | $rc = new \ReflectionFunction($var); 181 | return "/* Closure defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */"; 182 | 183 | } elseif ($var instanceof \UnitEnum) { 184 | return $var::class . '::' . $var->name; 185 | 186 | } elseif (is_object($var)) { 187 | if (($rc = new \ReflectionObject($var))->isAnonymous()) { 188 | return "/* Anonymous class defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */"; 189 | } 190 | 191 | $arr = (array) $var; 192 | $space = str_repeat("\t", $level); 193 | $class = $var::class; 194 | $used = &$list[spl_object_hash($var)]; 195 | 196 | if (empty($arr)) { 197 | $out = ''; 198 | 199 | } elseif ($used) { 200 | return "/* $class dumped on line $used */"; 201 | 202 | } elseif ($level > self::$maxDepth) { 203 | return '/* Nesting level too deep */'; 204 | 205 | } else { 206 | $out = "\n"; 207 | $used = $line; 208 | $line++; 209 | foreach ($arr as $k => &$v) { 210 | if (isset($k[0]) && $k[0] === "\x00") { 211 | $k = substr($k, strrpos($k, "\x00") + 1); 212 | } 213 | 214 | $out .= "$space\t" . self::_toPhp($k, $list, $level + 1, $line) . ' => ' . self::_toPhp($v, $list, $level + 1, $line) . ",\n"; 215 | $line++; 216 | } 217 | 218 | $out .= $space; 219 | } 220 | 221 | $hash = self::hash($var); 222 | return $class === 'stdClass' 223 | ? "(object) /* $hash */ [$out]" 224 | : "$class::__set_state(/* $hash */ [$out])"; 225 | 226 | } elseif (is_resource($var)) { 227 | return '/* resource ' . get_resource_type($var) . ' */'; 228 | 229 | } else { 230 | return var_export($var, return: true); 231 | } 232 | } 233 | 234 | 235 | private static function encodeStringPhp(string $s): string 236 | { 237 | $special = [ 238 | "\r" => '\r', 239 | "\n" => '\n', 240 | "\t" => "\t", 241 | "\e" => '\e', 242 | '\\' => '\\\\', 243 | ]; 244 | $utf8 = preg_match('##u', $s); 245 | $escaped = preg_replace_callback( 246 | $utf8 ? '#[\p{C}\\\]#u' : '#[\x00-\x1F\x7F-\xFF\\\]#', 247 | fn($m) => $special[$m[0]] ?? (strlen($m[0]) === 1 248 | ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) . '' 249 | : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}'), 250 | $s, 251 | ); 252 | return $s === str_replace('\\\\', '\\', $escaped) 253 | ? "'" . preg_replace('#\'|\\\(?=[\'\\\]|$)#D', '\\\$0', $s) . "'" 254 | : '"' . addcslashes($escaped, '"$') . '"'; 255 | } 256 | 257 | 258 | private static function encodeStringLine(string $s): string 259 | { 260 | $special = [ 261 | "\r" => "\\r\r", 262 | "\n" => "\\n\n", 263 | "\t" => "\\t\t", 264 | "\e" => '\e', 265 | "'" => "'", 266 | ]; 267 | $utf8 = preg_match('##u', $s); 268 | $escaped = preg_replace_callback( 269 | $utf8 ? '#[\p{C}\']#u' : '#[\x00-\x1F\x7F-\xFF\']#', 270 | fn($m) => "\e[22m" 271 | . ($special[$m[0]] ?? (strlen($m[0]) === 1 272 | ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) 273 | : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}')) 274 | . "\e[1m", 275 | $s, 276 | ); 277 | return "'" . $escaped . "'"; 278 | } 279 | 280 | 281 | private static function utf8Ord(string $c): int 282 | { 283 | $ord0 = ord($c[0]); 284 | if ($ord0 < 0x80) { 285 | return $ord0; 286 | } elseif ($ord0 < 0xE0) { 287 | return ($ord0 << 6) + ord($c[1]) - 0x3080; 288 | } elseif ($ord0 < 0xF0) { 289 | return ($ord0 << 12) + (ord($c[1]) << 6) + ord($c[2]) - 0xE2080; 290 | } else { 291 | return ($ord0 << 18) + (ord($c[1]) << 12) + (ord($c[2]) << 6) + ord($c[3]) - 0x3C82080; 292 | } 293 | } 294 | 295 | 296 | public static function dumpException(\Throwable $e): string 297 | { 298 | $trace = $e->getTrace(); 299 | array_splice($trace, 0, $e instanceof \ErrorException ? 1 : 0, [['file' => $e->getFile(), 'line' => $e->getLine()]]); 300 | 301 | $testFile = null; 302 | foreach (array_reverse($trace) as $item) { 303 | if (isset($item['file'])) { // in case of shutdown handler, we want to skip inner-code blocks and debugging calls 304 | $testFile = $item['file']; 305 | break; 306 | } 307 | } 308 | 309 | if ($e instanceof AssertException) { 310 | $expected = $e->expected; 311 | $actual = $e->actual; 312 | $testFile = $e->outputName 313 | ? dirname($testFile) . '/' . $e->outputName . '.foo' 314 | : $testFile; 315 | 316 | if (is_object($expected) || is_array($expected) || (is_string($expected) && strlen($expected) > self::$maxLength) 317 | || is_object($actual) || is_array($actual) || (is_string($actual) && (strlen($actual) > self::$maxLength || preg_match('#[\x00-\x1F]#', $actual))) 318 | ) { 319 | $args = isset($_SERVER['argv'][1]) 320 | ? '.[' . implode(' ', preg_replace(['#^-*([^|]+).*#i', '#[^=a-z0-9. -]+#i'], ['$1', '-'], array_slice($_SERVER['argv'], 1))) . ']' 321 | : ''; 322 | $stored[] = self::saveOutput($testFile, $expected, $args . '.expected'); 323 | $stored[] = self::saveOutput($testFile, $actual, $args . '.actual'); 324 | } 325 | 326 | if ((is_string($actual) && is_string($expected))) { 327 | for ($i = 0; $i < strlen($actual) && isset($expected[$i]) && $actual[$i] === $expected[$i]; $i++); 328 | for (; $i && $i < strlen($actual) && $actual[$i - 1] >= "\x80" && $actual[$i] >= "\x80" && $actual[$i] < "\xC0"; $i--); 329 | $i = max(0, min( 330 | $i - (int) (self::$maxLength / 3), // try to display 1/3 of shorter string 331 | max(strlen($actual), strlen($expected)) - self::$maxLength + 3, // 3 = length of ... 332 | )); 333 | if ($i) { 334 | $expected = substr_replace($expected, '...', 0, $i); 335 | $actual = substr_replace($actual, '...', 0, $i); 336 | } 337 | } 338 | 339 | $message = 'Failed: ' . $e->origMessage; 340 | if (((is_string($actual) && is_string($expected)) || (is_array($actual) && is_array($expected))) 341 | && preg_match('#^(.*)(%\d)(.*)(%\d.*)$#Ds', $message, $m) 342 | ) { 343 | $message = ($delta = strlen($m[1]) - strlen($m[3])) >= 3 344 | ? "$m[1]$m[2]\n" . str_repeat(' ', $delta - 3) . "...$m[3]$m[4]" 345 | : "$m[1]$m[2]$m[3]\n" . str_repeat(' ', strlen($m[1]) - 4) . "... $m[4]"; 346 | } 347 | 348 | $message = strtr($message, [ 349 | '%1' => self::color('yellow') . self::toLine($actual) . self::color('white'), 350 | '%2' => self::color('yellow') . self::toLine($expected) . self::color('white'), 351 | ]); 352 | } else { 353 | $message = ($e instanceof \ErrorException ? Helpers::errorTypeToString($e->getSeverity()) : $e::class) 354 | . ': ' . preg_replace('#[\x00-\x09\x0B-\x1F]+#', ' ', $e->getMessage()); 355 | } 356 | 357 | $s = self::color('white', $message) . "\n\n" 358 | . (isset($stored) ? 'diff ' . Helpers::escapeArg($stored[0]) . ' ' . Helpers::escapeArg($stored[1]) . "\n\n" : ''); 359 | 360 | foreach ($trace as $item) { 361 | $item += ['file' => null, 'class' => null, 'type' => null, 'function' => null]; 362 | if ($e instanceof AssertException && $item['file'] === __DIR__ . DIRECTORY_SEPARATOR . 'Assert.php') { 363 | continue; 364 | } 365 | 366 | $line = $item['class'] === Assert::class && method_exists($item['class'], $item['function']) 367 | && strpos($tmp = file($item['file'])[$item['line'] - 1], "::$item[function](") ? $tmp : null; 368 | 369 | $s .= 'in ' 370 | . ($item['file'] 371 | ? ( 372 | ($item['file'] === $testFile ? self::color('white') : '') 373 | . implode( 374 | self::$pathSeparator ?? DIRECTORY_SEPARATOR, 375 | array_slice(explode(DIRECTORY_SEPARATOR, $item['file']), -self::$maxPathSegments), 376 | ) 377 | . "($item[line])" . self::color('gray') . ' ' 378 | ) 379 | : '[internal function]' 380 | ) 381 | . ($line 382 | ? trim($line) 383 | : $item['class'] . $item['type'] . $item['function'] . ($item['function'] ? '()' : '') 384 | ) 385 | . self::color() . "\n"; 386 | } 387 | 388 | if ($e->getPrevious()) { 389 | $s .= "\n(previous) " . static::dumpException($e->getPrevious()); 390 | } 391 | 392 | return $s; 393 | } 394 | 395 | 396 | /** 397 | * Dumps data to folder 'output'. 398 | */ 399 | public static function saveOutput(string $testFile, mixed $content, string $suffix = ''): string 400 | { 401 | $path = self::$dumpDir . DIRECTORY_SEPARATOR . pathinfo($testFile, PATHINFO_FILENAME) . $suffix; 402 | if (!preg_match('#/|\w:#A', self::$dumpDir)) { 403 | $path = dirname($testFile) . DIRECTORY_SEPARATOR . $path; 404 | } 405 | 406 | @mkdir(dirname($path)); // @ - directory may already exist 407 | file_put_contents($path, is_string($content) ? $content : (self::toPhp($content) . "\n")); 408 | return $path; 409 | } 410 | 411 | 412 | /** 413 | * Applies color to string. 414 | */ 415 | public static function color(string $color = '', ?string $s = null): string 416 | { 417 | $colors = [ 418 | 'black' => '0;30', 'gray' => '1;30', 'silver' => '0;37', 'white' => '1;37', 419 | 'navy' => '0;34', 'blue' => '1;34', 'green' => '0;32', 'lime' => '1;32', 420 | 'teal' => '0;36', 'aqua' => '1;36', 'maroon' => '0;31', 'red' => '1;31', 421 | 'purple' => '0;35', 'fuchsia' => '1;35', 'olive' => '0;33', 'yellow' => '1;33', 422 | '' => '0', 423 | ]; 424 | $c = explode('/', $color); 425 | return "\e[" 426 | . str_replace(';', "m\e[", $colors[$c[0]] . (empty($c[1]) ? '' : ';4' . substr($colors[$c[1]], -1))) 427 | . 'm' . $s . ($s === null ? '' : "\e[0m"); 428 | } 429 | 430 | 431 | public static function removeColors(string $s): string 432 | { 433 | return preg_replace('#\e\[[\d;]+m#', '', $s); 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /src/CodeCoverage/Generators/template.phtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <?= $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage 9 | 10 | 212 | 213 | 214 | 215 | 216 | name); 221 | 222 | $currentFile = ''; 223 | foreach ($keys as $key) { 224 | $currentFile = $currentFile . ($currentFile !== '' ? DIRECTORY_SEPARATOR : '') . $key; 225 | $arr = &$arr['files'][$key]; 226 | 227 | if (!isset($arr['name'])) { 228 | $arr['name'] = $currentFile; 229 | } 230 | $arr['count'] = isset($arr['count']) ? $arr['count'] + 1 : 1; 231 | $arr['coverage'] = isset($arr['coverage']) ? $arr['coverage'] + $info->coverage : $info->coverage; 232 | } 233 | $arr = $value; 234 | } 235 | 236 | $jsonData = []; 237 | $directories = []; 238 | $allLinesCount = 0; 239 | foreach ($files as $id => $info) { 240 | $code = file_get_contents($info->file); 241 | $lineCount = substr_count($code, "\n") + 1; 242 | $digits = ceil(log10($lineCount)) + 1; 243 | 244 | $allLinesCount += $lineCount; 245 | 246 | $currentId = "F{$id}"; 247 | assignArrayByPath($directories, $info, $currentId); 248 | 249 | $html = highlight_string($code, true); 250 | if (PHP_VERSION_ID < 80300) { // Normalize to HTML introduced by PHP 8.3 251 | $html = preg_replace( 252 | [ 253 | '#^\n#', 255 | '# #', 256 | '#\n\n$#' 257 | ], 258 | [ 259 | '', 263 | ], 264 | $html, 265 | ); 266 | $html = "
$html
"; 267 | } 268 | 269 | $data = (array) $info; 270 | $data['digits'] = $digits; 271 | $data['lineCount'] = $lineCount; 272 | $data['content'] = strtr($html, [ 273 | '
' => "
",
274 | 		'' => '',
276 | 	]);
277 | 	$jsonData[$currentId] = $data;
278 | } ?>
279 | 
280 | 

281 | Code coverage  % 282 | sources have lines of code in files 283 |

284 | 285 | 289 | 290 |
291 |
292 | 293 | 294 | 295 | 298 | 301 | 304 | 305 | 306 |
296 |  % 297 | 299 |
300 |
302 | path  303 |
307 |
308 |
309 | 310 |
311 | 312 | 315 | 316 | 586 | 587 | 588 | -------------------------------------------------------------------------------- /src/Framework/Assert.php: -------------------------------------------------------------------------------- 1 | '%', // one % character 27 | '%a%' => '[^\r\n]+', // one or more of anything except the end of line characters 28 | '%a\?%' => '[^\r\n]*', // zero or more of anything except the end of line characters 29 | '%A%' => '.+', // one or more of anything including the end of line characters 30 | '%A\?%' => '.*', // zero or more of anything including the end of line characters 31 | '%s%' => '[\t ]+', // one or more white space characters except the end of line characters 32 | '%s\?%' => '[\t ]*', // zero or more white space characters except the end of line characters 33 | '%S%' => '\S+', // one or more of characters except the white space 34 | '%S\?%' => '\S*', // zero or more of characters except the white space 35 | '%c%' => '[^\r\n]', // a single character of any sort (except the end of line) 36 | '%d%' => '[0-9]+', // one or more digits 37 | '%d\?%' => '[0-9]*', // zero or more digits 38 | '%i%' => '[+-]?[0-9]+', // signed integer value 39 | '%f%' => '[+-]?\.?\d+\.?\d*(?:[Ee][+-]?\d+)?', // floating point number 40 | '%h%' => '[0-9a-fA-F]+', // one or more HEX digits 41 | '%w%' => '[0-9a-zA-Z_]+', //one or more alphanumeric characters 42 | '%ds%' => '[\\\/]', // directory separator 43 | '%(\[.+\][+*?{},\d]*)%' => '$1', // range 44 | ]; 45 | 46 | /** expand patterns in match() and matchFile() */ 47 | public static bool $expandPatterns = true; 48 | 49 | /** @var callable function (AssertException $exception): void */ 50 | public static $onFailure; 51 | public static int $counter = 0; 52 | 53 | 54 | /** 55 | * Asserts that two values are equal and have the same type and identity of objects. 56 | */ 57 | public static function same(mixed $expected, mixed $actual, ?string $description = null): void 58 | { 59 | self::$counter++; 60 | if ($actual !== $expected) { 61 | self::fail(self::describe('%1 should be %2', $description), $actual, $expected); 62 | } 63 | } 64 | 65 | 66 | /** 67 | * Asserts that two values are not equal or do not have the same type and identity of objects. 68 | */ 69 | public static function notSame(mixed $expected, mixed $actual, ?string $description = null): void 70 | { 71 | self::$counter++; 72 | if ($actual === $expected) { 73 | self::fail(self::describe('%1 should not be %2', $description), $actual, $expected); 74 | } 75 | } 76 | 77 | 78 | /** 79 | * Asserts that two values are equal and checks expectations. The identity of objects, 80 | * the order of keys in the arrays and marginally different floats are ignored by default. 81 | */ 82 | public static function equal( 83 | mixed $expected, 84 | mixed $actual, 85 | ?string $description = null, 86 | bool $matchOrder = false, 87 | bool $matchIdentity = false, 88 | ): void 89 | { 90 | self::$counter++; 91 | if (!self::isEqual($expected, $actual, $matchOrder, $matchIdentity)) { 92 | self::fail(self::describe('%1 should be equal to %2', $description), $actual, $expected); 93 | } 94 | } 95 | 96 | 97 | /** 98 | * Asserts that two values are not equal and checks expectations. The identity of objects, 99 | * the order of keys in the arrays and marginally different floats are ignored. 100 | */ 101 | public static function notEqual(mixed $expected, mixed $actual, ?string $description = null): void 102 | { 103 | self::$counter++; 104 | try { 105 | $res = self::isEqual($expected, $actual, matchOrder: false, matchIdentity: false); 106 | } catch (AssertException $e) { 107 | } 108 | 109 | if (empty($e) && $res) { 110 | self::fail(self::describe('%1 should not be equal to %2', $description), $actual, $expected); 111 | } 112 | } 113 | 114 | 115 | /** 116 | * Asserts that a haystack (string or array) contains an expected needle. 117 | */ 118 | public static function contains(mixed $needle, array|string $actual, ?string $description = null): void 119 | { 120 | self::$counter++; 121 | if (is_array($actual)) { 122 | if (!in_array($needle, $actual, true)) { 123 | self::fail(self::describe('%1 should contain %2', $description), $actual, $needle); 124 | } 125 | } elseif (!is_string($needle)) { 126 | self::fail(self::describe('Needle %1 should be string'), $needle); 127 | 128 | } elseif ($needle !== '' && !str_contains($actual, $needle)) { 129 | self::fail(self::describe('%1 should contain %2', $description), $actual, $needle); 130 | } 131 | } 132 | 133 | 134 | /** 135 | * Asserts that a haystack (string or array) does not contain an expected needle. 136 | */ 137 | public static function notContains(mixed $needle, array|string $actual, ?string $description = null): void 138 | { 139 | self::$counter++; 140 | if (is_array($actual)) { 141 | if (in_array($needle, $actual, true)) { 142 | self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle); 143 | } 144 | } elseif (!is_string($needle)) { 145 | self::fail(self::describe('Needle %1 should be string'), $needle); 146 | 147 | } elseif ($needle === '' || str_contains($actual, $needle)) { 148 | self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle); 149 | } 150 | } 151 | 152 | 153 | /** 154 | * Asserts that a haystack has an expected key. 155 | */ 156 | public static function hasKey(string|int $key, array $actual, ?string $description = null): void 157 | { 158 | self::$counter++; 159 | if (!array_key_exists($key, $actual)) { 160 | self::fail(self::describe('%1 should contain key %2', $description), $actual, $key); 161 | } 162 | } 163 | 164 | 165 | /** 166 | * Asserts that a haystack doesn't have an expected key. 167 | */ 168 | public static function hasNotKey(string|int $key, array $actual, ?string $description = null): void 169 | { 170 | self::$counter++; 171 | if (array_key_exists($key, $actual)) { 172 | self::fail(self::describe('%1 should not contain key %2', $description), $actual, $key); 173 | } 174 | } 175 | 176 | 177 | /** 178 | * Asserts that a value is true. 179 | */ 180 | public static function true(mixed $actual, ?string $description = null): void 181 | { 182 | self::$counter++; 183 | if ($actual !== true) { 184 | self::fail(self::describe('%1 should be true', $description), $actual); 185 | } 186 | } 187 | 188 | 189 | /** 190 | * Asserts that a value is false. 191 | */ 192 | public static function false(mixed $actual, ?string $description = null): void 193 | { 194 | self::$counter++; 195 | if ($actual !== false) { 196 | self::fail(self::describe('%1 should be false', $description), $actual); 197 | } 198 | } 199 | 200 | 201 | /** 202 | * Asserts that a value is null. 203 | */ 204 | public static function null(mixed $actual, ?string $description = null): void 205 | { 206 | self::$counter++; 207 | if ($actual !== null) { 208 | self::fail(self::describe('%1 should be null', $description), $actual); 209 | } 210 | } 211 | 212 | 213 | /** 214 | * Asserts that a value is not null. 215 | */ 216 | public static function notNull(mixed $actual, ?string $description = null): void 217 | { 218 | self::$counter++; 219 | if ($actual === null) { 220 | self::fail(self::describe('Value should not be null', $description)); 221 | } 222 | } 223 | 224 | 225 | /** 226 | * Asserts that a value is Not a Number. 227 | */ 228 | public static function nan(mixed $actual, ?string $description = null): void 229 | { 230 | self::$counter++; 231 | if (!is_float($actual) || !is_nan($actual)) { 232 | self::fail(self::describe('%1 should be NAN', $description), $actual); 233 | } 234 | } 235 | 236 | 237 | /** 238 | * Asserts that a value is truthy. 239 | */ 240 | public static function truthy(mixed $actual, ?string $description = null): void 241 | { 242 | self::$counter++; 243 | if (!$actual) { 244 | self::fail(self::describe('%1 should be truthy', $description), $actual); 245 | } 246 | } 247 | 248 | 249 | /** 250 | * Asserts that a value is falsey. 251 | */ 252 | public static function falsey(mixed $actual, ?string $description = null): void 253 | { 254 | self::$counter++; 255 | if ($actual) { 256 | self::fail(self::describe('%1 should be falsey', $description), $actual); 257 | } 258 | } 259 | 260 | 261 | /** 262 | * Asserts the number of items in an array or Countable. 263 | */ 264 | public static function count(int $count, array|\Countable $value, ?string $description = null): void 265 | { 266 | self::$counter++; 267 | if (count($value) !== $count) { 268 | self::fail(self::describe('Count %1 should be %2', $description), count($value), $count); 269 | } 270 | } 271 | 272 | 273 | /** 274 | * Asserts that a value is of given class, interface or built-in type. 275 | */ 276 | public static function type(string|object $type, mixed $value, ?string $description = null): void 277 | { 278 | self::$counter++; 279 | if ($type === 'list') { 280 | if (!is_array($value) || ($value && array_keys($value) !== range(0, count($value) - 1))) { 281 | self::fail(self::describe("%1 should be $type", $description), $value); 282 | } 283 | } elseif (in_array($type, ['array', 'bool', 'callable', 'float', 284 | 'int', 'integer', 'null', 'object', 'resource', 'scalar', 'string', ], true) 285 | ) { 286 | if (!("is_$type")($value)) { 287 | self::fail(self::describe(get_debug_type($value) . " should be $type", $description)); 288 | } 289 | } elseif (!$value instanceof $type) { 290 | $actual = get_debug_type($value); 291 | $type = is_object($type) ? $type::class : $type; 292 | self::fail(self::describe("$actual should be instance of $type", $description)); 293 | } 294 | } 295 | 296 | 297 | /** 298 | * Asserts that a function throws exception of given type and its message matches given pattern. 299 | */ 300 | public static function exception( 301 | callable $function, 302 | string $class, 303 | ?string $message = null, 304 | $code = null, 305 | ): ?\Throwable 306 | { 307 | self::$counter++; 308 | $e = null; 309 | try { 310 | $function(); 311 | } catch (\Throwable $e) { 312 | } 313 | 314 | if ($e === null) { 315 | self::fail("$class was expected, but none was thrown"); 316 | 317 | } elseif (!$e instanceof $class) { 318 | self::fail("$class was expected but got " . $e::class . ($e->getMessage() ? " ({$e->getMessage()})" : ''), null, null, $e); 319 | 320 | } elseif ($message && !self::isMatching($message, $e->getMessage())) { 321 | self::fail("$class with a message matching %2 was expected but got %1", $e->getMessage(), $message, $e); 322 | 323 | } elseif ($code !== null && $e->getCode() !== $code) { 324 | self::fail("$class with a code %2 was expected but got %1", $e->getCode(), $code, $e); 325 | } 326 | 327 | return $e; 328 | } 329 | 330 | 331 | /** 332 | * Asserts that a function throws exception of given type and its message matches given pattern. Alias for exception(). 333 | */ 334 | public static function throws( 335 | callable $function, 336 | string $class, 337 | ?string $message = null, 338 | mixed $code = null, 339 | ): ?\Throwable 340 | { 341 | return self::exception($function, $class, $message, $code); 342 | } 343 | 344 | 345 | /** 346 | * Asserts that a function generates one or more PHP errors or throws exceptions. 347 | * @throws \Exception 348 | */ 349 | public static function error( 350 | callable $function, 351 | int|string|array $expectedType, 352 | ?string $expectedMessage = null, 353 | ): ?\Throwable 354 | { 355 | if (is_string($expectedType) && !preg_match('#^E_[A-Z_]+$#D', $expectedType)) { 356 | return static::exception($function, $expectedType, $expectedMessage); 357 | } 358 | 359 | self::$counter++; 360 | $expected = is_array($expectedType) ? $expectedType : [[$expectedType, $expectedMessage]]; 361 | foreach ($expected as &$item) { 362 | $item = ((array) $item) + [null, null]; 363 | $expectedType = $item[0]; 364 | if (is_int($expectedType)) { 365 | $item[2] = Helpers::errorTypeToString($expectedType); 366 | } elseif (is_string($expectedType)) { 367 | $item[0] = constant($item[2] = $expectedType); 368 | } else { 369 | throw new \Exception('Error type must be E_* constant.'); 370 | } 371 | } 372 | 373 | set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$expected) { 374 | if (($severity & error_reporting()) !== $severity) { 375 | return; 376 | } 377 | 378 | $errorStr = Helpers::errorTypeToString($severity) . ($message ? " ($message)" : ''); 379 | [$expectedType, $expectedMessage, $expectedTypeStr] = array_shift($expected); 380 | if ($expectedType === null) { 381 | self::fail("Generated more errors than expected: $errorStr was generated in file $file on line $line"); 382 | 383 | } elseif ($severity !== $expectedType) { 384 | self::fail("$expectedTypeStr was expected, but $errorStr was generated in file $file on line $line"); 385 | 386 | } elseif ($expectedMessage && !self::isMatching($expectedMessage, $message)) { 387 | self::fail("$expectedTypeStr with a message matching %2 was expected but got %1", $message, $expectedMessage); 388 | } 389 | }); 390 | 391 | reset($expected); 392 | try { 393 | $function(); 394 | restore_error_handler(); 395 | } catch (\Throwable $e) { 396 | restore_error_handler(); 397 | throw $e; 398 | } 399 | 400 | if ($expected) { 401 | self::fail('Error was expected, but was not generated'); 402 | } 403 | 404 | return null; 405 | } 406 | 407 | 408 | /** 409 | * Asserts that a function does not generate PHP errors and does not throw exceptions. 410 | */ 411 | public static function noError(callable $function): void 412 | { 413 | if (($count = func_num_args()) > 1) { 414 | throw new \Exception(__METHOD__ . "() expects 1 parameter, $count given."); 415 | } 416 | 417 | self::error($function, []); 418 | } 419 | 420 | 421 | /** 422 | * Asserts that a string matches a given pattern. 423 | * %a% one or more of anything except the end of line characters 424 | * %a?% zero or more of anything except the end of line characters 425 | * %A% one or more of anything including the end of line characters 426 | * %A?% zero or more of anything including the end of line characters 427 | * %s% one or more white space characters except the end of line characters 428 | * %s?% zero or more white space characters except the end of line characters 429 | * %S% one or more of characters except the white space 430 | * %S?% zero or more of characters except the white space 431 | * %c% a single character of any sort (except the end of line) 432 | * %d% one or more digits 433 | * %d?% zero or more digits 434 | * %i% signed integer value 435 | * %f% floating point number 436 | * %h% one or more HEX digits 437 | * @param string $pattern mask|regexp; only delimiters ~ and # are supported for regexp 438 | */ 439 | public static function match(string $pattern, string $actual, ?string $description = null): void 440 | { 441 | self::$counter++; 442 | if (!self::isMatching($pattern, $actual)) { 443 | if (self::$expandPatterns) { 444 | [$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual); 445 | } 446 | 447 | self::fail(self::describe('%1 should match %2', $description), $actual, $pattern); 448 | } 449 | } 450 | 451 | 452 | public static function notMatch(string $pattern, string $actual, ?string $description = null): void 453 | { 454 | self::$counter++; 455 | if (self::isMatching($pattern, $actual)) { 456 | if (self::$expandPatterns) { 457 | [$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual); 458 | } 459 | 460 | self::fail(self::describe('%1 should not match %2', $description), $actual, $pattern); 461 | } 462 | } 463 | 464 | 465 | /** 466 | * Asserts that a string matches a given pattern stored in file. 467 | */ 468 | public static function matchFile(string $file, string $actual, ?string $description = null): void 469 | { 470 | self::$counter++; 471 | $pattern = @file_get_contents($file); // @ is escalated to exception 472 | if ($pattern === false) { 473 | throw new \Exception("Unable to read file '$file'."); 474 | 475 | } elseif (!self::isMatching($pattern, $actual)) { 476 | if (self::$expandPatterns) { 477 | [$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual); 478 | } 479 | 480 | self::fail(self::describe('%1 should match %2', $description), $actual, $pattern, null, basename($file)); 481 | } 482 | } 483 | 484 | 485 | /** 486 | * Assertion that fails. 487 | */ 488 | public static function fail( 489 | string $message, 490 | $actual = null, 491 | $expected = null, 492 | ?\Throwable $previous = null, 493 | ?string $outputName = null, 494 | ): void 495 | { 496 | $e = new AssertException($message, $expected, $actual, $previous); 497 | $e->outputName = $outputName; 498 | if (self::$onFailure) { 499 | (self::$onFailure)($e); 500 | } else { 501 | throw $e; 502 | } 503 | } 504 | 505 | 506 | private static function describe(string $reason, ?string $description = null): string 507 | { 508 | return ($description ? $description . ': ' : '') . $reason; 509 | } 510 | 511 | 512 | /** 513 | * Executes function that can access private and protected members of given object via $this. 514 | */ 515 | public static function with(object|string $objectOrClass, \Closure $closure): mixed 516 | { 517 | return $closure->bindTo(is_object($objectOrClass) ? $objectOrClass : null, $objectOrClass)(); 518 | } 519 | 520 | 521 | /********************* helpers ****************d*g**/ 522 | 523 | 524 | /** 525 | * Compares using mask. 526 | * @internal 527 | */ 528 | public static function isMatching(string $pattern, string $actual, bool $strict = false): bool 529 | { 530 | $old = ini_set('pcre.backtrack_limit', '10000000'); 531 | 532 | if (!self::isPcre($pattern)) { 533 | $utf8 = preg_match('#\x80-\x{10FFFF}]#u', $pattern) ? 'u' : ''; 534 | $suffix = ($strict ? '$#DsU' : '\s*$#sU') . $utf8; 535 | $patterns = static::$patterns + [ 536 | '[.\\\+*?[^$(){|\#]' => '\$0', // preg quoting 537 | '\x00' => '\x00', 538 | '[\t ]*\r?\n' => '[\t ]*\r?\n', // right trim 539 | ]; 540 | $pattern = '#^' . preg_replace_callback('#' . implode('|', array_keys($patterns)) . '#U' . $utf8, function ($m) use ($patterns) { 541 | foreach ($patterns as $re => $replacement) { 542 | $s = preg_replace("#^$re$#D", str_replace('\\', '\\\\', $replacement), $m[0], 1, $count); 543 | if ($count) { 544 | return $s; 545 | } 546 | } 547 | }, rtrim($pattern, " \t\n\r")) . $suffix; 548 | } 549 | 550 | $res = preg_match($pattern, (string) $actual); 551 | ini_set('pcre.backtrack_limit', $old); 552 | if ($res === false || preg_last_error()) { 553 | throw new \Exception('Error while executing regular expression. (' . preg_last_error_msg() . ')'); 554 | } 555 | 556 | return (bool) $res; 557 | } 558 | 559 | 560 | /** 561 | * @internal 562 | */ 563 | public static function expandMatchingPatterns(string $pattern, string $actual): array 564 | { 565 | if (self::isPcre($pattern)) { 566 | return [$pattern, $actual]; 567 | } 568 | 569 | $parts = preg_split('#(%)#', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE); 570 | for ($i = count($parts); $i >= 0; $i--) { 571 | $patternX = implode('', array_slice($parts, 0, $i)); 572 | $patternY = "$patternX%A?%"; 573 | if (self::isMatching($patternY, $actual)) { 574 | $patternZ = implode('', array_slice($parts, $i)); 575 | break; 576 | } 577 | } 578 | 579 | foreach (['%A%', '%A?%'] as $greedyPattern) { 580 | if (substr($patternX, -strlen($greedyPattern)) === $greedyPattern) { 581 | $patternX = substr($patternX, 0, -strlen($greedyPattern)); 582 | $patternY = "$patternX%A?%"; 583 | $patternZ = $greedyPattern . $patternZ; 584 | break; 585 | } 586 | } 587 | 588 | $low = 0; 589 | $high = strlen($actual); 590 | while ($low <= $high) { 591 | $mid = ($low + $high) >> 1; 592 | if (self::isMatching($patternY, substr($actual, 0, $mid))) { 593 | $high = $mid - 1; 594 | } else { 595 | $low = $mid + 1; 596 | } 597 | } 598 | 599 | $low = $high + 2; 600 | $high = strlen($actual); 601 | while ($low <= $high) { 602 | $mid = ($low + $high) >> 1; 603 | if (!self::isMatching($patternX, substr($actual, 0, $mid), strict: true)) { 604 | $high = $mid - 1; 605 | } else { 606 | $low = $mid + 1; 607 | } 608 | } 609 | 610 | $actualX = substr($actual, 0, $high); 611 | $actualZ = substr($actual, $high); 612 | 613 | return [ 614 | $actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $patternZ)), 615 | $actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $actualZ)), 616 | ]; 617 | } 618 | 619 | 620 | /** 621 | * Compares two structures and checks expectations. The identity of objects, the order of keys 622 | * in the arrays and marginally different floats are ignored. 623 | */ 624 | private static function isEqual( 625 | mixed $expected, 626 | mixed $actual, 627 | bool $matchOrder, 628 | bool $matchIdentity, 629 | int $level = 0, 630 | ?\SplObjectStorage $objects = null, 631 | ): bool 632 | { 633 | switch (true) { 634 | case $level > 10: 635 | throw new \Exception('Nesting level too deep or recursive dependency.'); 636 | 637 | case $expected instanceof Expect: 638 | $expected($actual); 639 | return true; 640 | 641 | case is_float($expected) && is_float($actual) && is_finite($expected) && is_finite($actual): 642 | $diff = abs($expected - $actual); 643 | return ($diff < self::Epsilon) || ($diff / max(abs($expected), abs($actual)) < self::Epsilon); 644 | 645 | case !$matchIdentity && is_object($expected) && is_object($actual) && $expected::class === $actual::class: 646 | $objects = $objects ? clone $objects : new \SplObjectStorage; 647 | if (isset($objects[$expected])) { 648 | return $objects[$expected] === $actual; 649 | } elseif ($expected === $actual) { 650 | return true; 651 | } 652 | 653 | $objects[$expected] = $actual; 654 | $objects[$actual] = $expected; 655 | $expected = (array) $expected; 656 | $actual = (array) $actual; 657 | // break omitted 658 | 659 | case is_array($expected) && is_array($actual): 660 | if ($matchOrder) { 661 | reset($expected); 662 | reset($actual); 663 | } else { 664 | ksort($expected, SORT_STRING); 665 | ksort($actual, SORT_STRING); 666 | } 667 | 668 | if (array_keys($expected) !== array_keys($actual)) { 669 | return false; 670 | } 671 | 672 | foreach ($expected as $value) { 673 | if (!self::isEqual($value, current($actual), $matchOrder, $matchIdentity, $level + 1, $objects)) { 674 | return false; 675 | } 676 | 677 | next($actual); 678 | } 679 | 680 | return true; 681 | 682 | default: 683 | return $expected === $actual; 684 | } 685 | } 686 | 687 | 688 | private static function isPcre(string $pattern): bool 689 | { 690 | return (bool) preg_match('/^([~#]).+(\1)[imsxUu]*$/Ds', $pattern); 691 | } 692 | } 693 | --------------------------------------------------------------------------------