├── composer.json ├── license.md ├── readme.md └── src ├── CodeCoverage ├── Collector.php ├── Generators │ ├── AbstractGenerator.php │ ├── CloverXMLGenerator.php │ ├── HtmlGenerator.php │ └── template.phtml └── PhpParser.php ├── Framework ├── Assert.php ├── AssertException.php ├── DataProvider.php ├── DomQuery.php ├── Dumper.php ├── Environment.php ├── Expect.php ├── FileMock.php ├── FileMutator.php ├── Helpers.php ├── TestCase.php └── functions.php ├── Runner ├── CliTester.php ├── CommandLine.php ├── Job.php ├── Output │ ├── ConsolePrinter.php │ ├── JUnitPrinter.php │ ├── Logger.php │ └── TapPrinter.php ├── OutputHandler.php ├── PhpInterpreter.php ├── Runner.php ├── Test.php ├── TestHandler.php ├── exceptions.php └── info.php ├── bootstrap.php ├── tester └── tester.php /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.4" 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.4. 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/CodeCoverage/Collector.php: -------------------------------------------------------------------------------- 1 | $engineInfo[0], self::detectEngines()), 57 | true, 58 | )) { 59 | throw new \LogicException("Code coverage engine '$engine' is not supported."); 60 | } 61 | 62 | self::$file = fopen($file, 'c+'); 63 | self::$engine = $engine; 64 | self::{'start' . $engine}(); 65 | 66 | register_shutdown_function(function (): void { 67 | register_shutdown_function([self::class, 'save']); 68 | }); 69 | } 70 | 71 | 72 | /** 73 | * Flushes all gathered information. Effective only with PHPDBG collector. 74 | */ 75 | public static function flush(): void 76 | { 77 | if (self::isStarted() && self::$engine === self::EnginePhpdbg) { 78 | self::save(); 79 | } 80 | } 81 | 82 | 83 | /** 84 | * Saves information about code coverage. Can be called repeatedly to free memory. 85 | * @throws \LogicException 86 | */ 87 | public static function save(): void 88 | { 89 | if (!self::isStarted()) { 90 | throw new \LogicException('Code coverage collector has not been started.'); 91 | } 92 | 93 | [$positive, $negative] = self::{'collect' . self::$engine}(); 94 | 95 | flock(self::$file, LOCK_EX); 96 | fseek(self::$file, 0); 97 | $rawContent = stream_get_contents(self::$file); 98 | $original = $rawContent ? unserialize($rawContent) : []; 99 | $coverage = array_replace_recursive($negative, $original, $positive); 100 | 101 | fseek(self::$file, 0); 102 | ftruncate(self::$file, 0); 103 | fwrite(self::$file, serialize($coverage)); 104 | flock(self::$file, LOCK_UN); 105 | } 106 | 107 | 108 | private static function startPCOV(): void 109 | { 110 | pcov\start(); 111 | } 112 | 113 | 114 | /** 115 | * Collects information about code coverage. 116 | */ 117 | private static function collectPCOV(): array 118 | { 119 | $positive = $negative = []; 120 | 121 | pcov\stop(); 122 | 123 | foreach (pcov\collect() as $file => $lines) { 124 | if (!file_exists($file)) { 125 | continue; 126 | } 127 | 128 | foreach ($lines as $num => $val) { 129 | if ($val > 0) { 130 | $positive[$file][$num] = $val; 131 | } else { 132 | $negative[$file][$num] = $val; 133 | } 134 | } 135 | } 136 | 137 | return [$positive, $negative]; 138 | } 139 | 140 | 141 | private static function startXdebug(): void 142 | { 143 | xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); 144 | } 145 | 146 | 147 | /** 148 | * Collects information about code coverage. 149 | */ 150 | private static function collectXdebug(): array 151 | { 152 | $positive = $negative = []; 153 | 154 | foreach (xdebug_get_code_coverage() as $file => $lines) { 155 | if (!file_exists($file)) { 156 | continue; 157 | } 158 | 159 | foreach ($lines as $num => $val) { 160 | if ($val > 0) { 161 | $positive[$file][$num] = $val; 162 | } else { 163 | $negative[$file][$num] = $val; 164 | } 165 | } 166 | } 167 | 168 | return [$positive, $negative]; 169 | } 170 | 171 | 172 | private static function startPhpDbg(): void 173 | { 174 | phpdbg_start_oplog(); 175 | } 176 | 177 | 178 | /** 179 | * Collects information about code coverage. 180 | */ 181 | private static function collectPhpDbg(): array 182 | { 183 | $positive = phpdbg_end_oplog(); 184 | $negative = phpdbg_get_executable(); 185 | 186 | foreach ($positive as $file => &$lines) { 187 | $lines = array_fill_keys(array_keys($lines), 1); 188 | } 189 | 190 | foreach ($negative as $file => &$lines) { 191 | $lines = array_fill_keys(array_keys($lines), -1); 192 | } 193 | 194 | phpdbg_start_oplog(); 195 | return [$positive, $negative]; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/CodeCoverage/Generators/AbstractGenerator.php: -------------------------------------------------------------------------------- 1 | data = @unserialize(file_get_contents($file)); // @ is escalated to exception 43 | if (!is_array($this->data)) { 44 | throw new \Exception("Content of file '$file' is invalid."); 45 | } 46 | 47 | $this->data = array_filter($this->data, fn(string $path): bool => @is_file($path), ARRAY_FILTER_USE_KEY); 48 | 49 | if (!$sources) { 50 | $sources = [Helpers::findCommonDirectory(array_keys($this->data))]; 51 | 52 | } else { 53 | foreach ($sources as $source) { 54 | if (!file_exists($source)) { 55 | throw new \Exception("File or directory '$source' is missing."); 56 | } 57 | } 58 | } 59 | 60 | $this->sources = array_map('realpath', $sources); 61 | } 62 | 63 | 64 | public function render(?string $file = null): void 65 | { 66 | $handle = $file ? @fopen($file, 'w') : STDOUT; // @ is escalated to exception 67 | if (!$handle) { 68 | throw new \Exception("Unable to write to file '$file'."); 69 | } 70 | 71 | ob_start(function (string $buffer) use ($handle) { fwrite($handle, $buffer); }, 4096); 72 | try { 73 | $this->renderSelf(); 74 | } catch (\Throwable $e) { 75 | } 76 | 77 | ob_end_flush(); 78 | fclose($handle); 79 | 80 | if (isset($e)) { 81 | if ($file) { 82 | unlink($file); 83 | } 84 | 85 | throw $e; 86 | } 87 | } 88 | 89 | 90 | public function getCoveredPercent(): float 91 | { 92 | return $this->totalSum ? $this->coveredSum * 100 / $this->totalSum : 0; 93 | } 94 | 95 | 96 | protected function getSourceIterator(): \Iterator 97 | { 98 | $iterator = new \AppendIterator; 99 | foreach ($this->sources as $source) { 100 | $iterator->append( 101 | is_dir($source) 102 | ? new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source)) 103 | : new \ArrayIterator([new \SplFileInfo($source)]), 104 | ); 105 | } 106 | 107 | return new \CallbackFilterIterator( 108 | $iterator, 109 | fn(\SplFileInfo $file): bool => $file->getBasename()[0] !== '.' // . or .. or .gitignore 110 | && in_array($file->getExtension(), $this->acceptFiles, true), 111 | ); 112 | } 113 | 114 | 115 | /** @deprecated */ 116 | protected static function getCommonFilesPath(array $files): string 117 | { 118 | return Helpers::findCommonDirectory($files); 119 | } 120 | 121 | 122 | abstract protected function renderSelf(): void; 123 | } 124 | -------------------------------------------------------------------------------- /src/CodeCoverage/Generators/CloverXMLGenerator.php: -------------------------------------------------------------------------------- 1 | 'packages', 21 | 'fileCount' => 'files', 22 | 'linesOfCode' => 'loc', 23 | 'linesOfNonCommentedCode' => 'ncloc', 24 | 'classCount' => 'classes', 25 | 'methodCount' => 'methods', 26 | 'coveredMethodCount' => 'coveredmethods', 27 | 'statementCount' => 'statements', 28 | 'coveredStatementCount' => 'coveredstatements', 29 | 'elementCount' => 'elements', 30 | 'coveredElementCount' => 'coveredelements', 31 | 'conditionalCount' => 'conditionals', 32 | 'coveredConditionalCount' => 'coveredconditionals', 33 | ]; 34 | 35 | 36 | public function __construct(string $file, array $sources = []) 37 | { 38 | if (!extension_loaded('dom') || !extension_loaded('tokenizer')) { 39 | throw new \LogicException('CloverXML generator requires DOM and Tokenizer extensions to be loaded.'); 40 | } 41 | 42 | parent::__construct($file, $sources); 43 | } 44 | 45 | 46 | protected function renderSelf(): void 47 | { 48 | $time = (string) time(); 49 | $parser = new PhpParser; 50 | 51 | $doc = new DOMDocument; 52 | $doc->formatOutput = true; 53 | 54 | $elCoverage = $doc->appendChild($doc->createElement('coverage')); 55 | $elCoverage->setAttribute('generated', $time); 56 | 57 | // TODO: @name 58 | $elProject = $elCoverage->appendChild($doc->createElement('project')); 59 | $elProject->setAttribute('timestamp', $time); 60 | $elProjectMetrics = $elProject->appendChild($doc->createElement('metrics')); 61 | 62 | $projectMetrics = (object) [ 63 | 'packageCount' => 0, 64 | 'fileCount' => 0, 65 | 'linesOfCode' => 0, 66 | 'linesOfNonCommentedCode' => 0, 67 | 'classCount' => 0, 68 | 'methodCount' => 0, 69 | 'coveredMethodCount' => 0, 70 | 'statementCount' => 0, 71 | 'coveredStatementCount' => 0, 72 | 'elementCount' => 0, 73 | 'coveredElementCount' => 0, 74 | 'conditionalCount' => 0, 75 | 'coveredConditionalCount' => 0, 76 | ]; 77 | 78 | foreach ($this->getSourceIterator() as $file) { 79 | $file = (string) $file; 80 | 81 | $projectMetrics->fileCount++; 82 | 83 | if (empty($this->data[$file])) { 84 | $coverageData = null; 85 | $this->totalSum += count(file($file, FILE_SKIP_EMPTY_LINES)); 86 | } else { 87 | $coverageData = $this->data[$file]; 88 | } 89 | 90 | // TODO: split to by namespace? 91 | $elFile = $elProject->appendChild($doc->createElement('file')); 92 | $elFile->setAttribute('name', $file); 93 | $elFileMetrics = $elFile->appendChild($doc->createElement('metrics')); 94 | 95 | try { 96 | $code = $parser->parse(file_get_contents($file)); 97 | } catch (\ParseError $e) { 98 | throw new \ParseError($e->getMessage() . ' in file ' . $file); 99 | } 100 | 101 | $fileMetrics = (object) [ 102 | 'linesOfCode' => $code->linesOfCode, 103 | 'linesOfNonCommentedCode' => $code->linesOfCode - $code->linesOfComments, 104 | 'classCount' => count($code->classes) + count($code->traits), 105 | 'methodCount' => 0, 106 | 'coveredMethodCount' => 0, 107 | 'statementCount' => 0, 108 | 'coveredStatementCount' => 0, 109 | 'elementCount' => 0, 110 | 'coveredElementCount' => 0, 111 | 'conditionalCount' => 0, 112 | 'coveredConditionalCount' => 0, 113 | ]; 114 | 115 | foreach (array_merge($code->classes, $code->traits) as $name => $info) { // TODO: interfaces? 116 | $elClass = $elFile->appendChild($doc->createElement('class')); 117 | if (($tmp = strrpos($name, '\\')) === false) { 118 | $elClass->setAttribute('name', $name); 119 | } else { 120 | $elClass->setAttribute('namespace', substr($name, 0, $tmp)); 121 | $elClass->setAttribute('name', substr($name, $tmp + 1)); 122 | } 123 | 124 | $elClassMetrics = $elClass->appendChild($doc->createElement('metrics')); 125 | $classMetrics = $this->calculateClassMetrics($info, $coverageData); 126 | self::setMetricAttributes($elClassMetrics, $classMetrics); 127 | self::appendMetrics($fileMetrics, $classMetrics); 128 | } 129 | 130 | self::setMetricAttributes($elFileMetrics, $fileMetrics); 131 | 132 | 133 | foreach ((array) $coverageData as $line => $count) { 134 | if ($count === self::LineDead) { 135 | continue; 136 | } 137 | 138 | // Line type can be 'method' but Xdebug does not report such lines as executed. 139 | $elLine = $elFile->appendChild($doc->createElement('line')); 140 | $elLine->setAttribute('num', (string) $line); 141 | $elLine->setAttribute('type', 'stmt'); 142 | $elLine->setAttribute('count', (string) max(0, $count)); 143 | 144 | $this->totalSum++; 145 | $this->coveredSum += $count > 0 ? 1 : 0; 146 | } 147 | 148 | self::appendMetrics($projectMetrics, $fileMetrics); 149 | } 150 | 151 | // TODO: What about reported (covered) lines outside of class/trait definition? 152 | self::setMetricAttributes($elProjectMetrics, $projectMetrics); 153 | 154 | echo $doc->saveXML(); 155 | } 156 | 157 | 158 | private function calculateClassMetrics(\stdClass $info, ?array $coverageData = null): \stdClass 159 | { 160 | $stats = (object) [ 161 | 'methodCount' => count($info->methods), 162 | 'coveredMethodCount' => 0, 163 | 'statementCount' => 0, 164 | 'coveredStatementCount' => 0, 165 | 'conditionalCount' => 0, 166 | 'coveredConditionalCount' => 0, 167 | 'elementCount' => null, 168 | 'coveredElementCount' => null, 169 | ]; 170 | 171 | foreach ($info->methods as $name => $methodInfo) { 172 | [$lineCount, $coveredLineCount] = $this->analyzeMethod($methodInfo, $coverageData); 173 | 174 | $stats->statementCount += $lineCount; 175 | 176 | if ($coverageData !== null) { 177 | $stats->coveredMethodCount += $lineCount === $coveredLineCount ? 1 : 0; 178 | $stats->coveredStatementCount += $coveredLineCount; 179 | } 180 | } 181 | 182 | $stats->elementCount = $stats->methodCount + $stats->statementCount; 183 | $stats->coveredElementCount = $stats->coveredMethodCount + $stats->coveredStatementCount; 184 | 185 | return $stats; 186 | } 187 | 188 | 189 | private static function analyzeMethod(\stdClass $info, ?array $coverageData = null): array 190 | { 191 | $count = 0; 192 | $coveredCount = 0; 193 | 194 | if ($coverageData === null) { // Never loaded file 195 | $count = max(1, $info->end - $info->start - 2); 196 | } else { 197 | for ($i = $info->start; $i <= $info->end; $i++) { 198 | if (isset($coverageData[$i]) && $coverageData[$i] !== self::LineDead) { 199 | $count++; 200 | if ($coverageData[$i] > 0) { 201 | $coveredCount++; 202 | } 203 | } 204 | } 205 | } 206 | 207 | return [$count, $coveredCount]; 208 | } 209 | 210 | 211 | private static function appendMetrics(\stdClass $summary, \stdClass $add): void 212 | { 213 | foreach ($add as $name => $value) { 214 | $summary->{$name} += $value; 215 | } 216 | } 217 | 218 | 219 | private static function setMetricAttributes(DOMElement $element, \stdClass $metrics): void 220 | { 221 | foreach ($metrics as $name => $value) { 222 | $element->setAttribute(self::$metricAttributesMap[$name], (string) $value); 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/CodeCoverage/Generators/HtmlGenerator.php: -------------------------------------------------------------------------------- 1 | 't', // tested 22 | self::LineUntested => 'u', // untested 23 | self::LineDead => 'dead', // dead code 24 | ]; 25 | private ?string $title; 26 | private array $files = []; 27 | 28 | 29 | /** 30 | * @param string $file path to coverage.dat file 31 | * @param array $sources files/directories 32 | */ 33 | public function __construct(string $file, array $sources = [], ?string $title = null) 34 | { 35 | parent::__construct($file, $sources); 36 | $this->title = $title; 37 | } 38 | 39 | 40 | protected function renderSelf(): void 41 | { 42 | $this->setupHighlight(); 43 | $this->parse(); 44 | 45 | $title = $this->title; 46 | $classes = self::Classes; 47 | $files = $this->files; 48 | $coveredPercent = $this->getCoveredPercent(); 49 | 50 | include __DIR__ . '/template.phtml'; 51 | } 52 | 53 | 54 | private function setupHighlight(): void 55 | { 56 | ini_set('highlight.comment', 'hc'); 57 | ini_set('highlight.default', 'hd'); 58 | ini_set('highlight.html', 'hh'); 59 | ini_set('highlight.keyword', 'hk'); 60 | ini_set('highlight.string', 'hs'); 61 | } 62 | 63 | 64 | private function parse(): void 65 | { 66 | if (count($this->files) > 0) { 67 | return; 68 | } 69 | 70 | $this->files = []; 71 | $commonSourcesPath = Helpers::findCommonDirectory($this->sources) . DIRECTORY_SEPARATOR; 72 | foreach ($this->getSourceIterator() as $entry) { 73 | $entry = (string) $entry; 74 | 75 | $coverage = $covered = $total = 0; 76 | $loaded = !empty($this->data[$entry]); 77 | $lines = []; 78 | if ($loaded) { 79 | $lines = $this->data[$entry]; 80 | foreach ($lines as $flag) { 81 | if ($flag >= self::LineUntested) { 82 | $total++; 83 | } 84 | 85 | if ($flag >= self::LineTested) { 86 | $covered++; 87 | } 88 | } 89 | 90 | $coverage = round($covered * 100 / $total); 91 | $this->totalSum += $total; 92 | $this->coveredSum += $covered; 93 | } else { 94 | $this->totalSum += count(file($entry, FILE_SKIP_EMPTY_LINES)); 95 | } 96 | 97 | $light = $total ? $total < 5 : count(file($entry)) < 50; 98 | $this->files[] = (object) [ 99 | 'name' => str_replace($commonSourcesPath, '', $entry), 100 | 'file' => $entry, 101 | 'lines' => $lines, 102 | 'coverage' => $coverage, 103 | 'total' => $total, 104 | 'class' => $light ? 'light' : ($loaded ? null : 'not-loaded'), 105 | ]; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /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/CodeCoverage/PhpParser.php: -------------------------------------------------------------------------------- 1 | $functionInfo], 29 | * classes: [className => $info], 30 | * traits: [traitName => $info], 31 | * interfaces: [interfaceName => $info], 32 | * } 33 | * 34 | * where $functionInfo is: 35 | * stdClass { 36 | * start: int, 37 | * end: int 38 | * } 39 | * 40 | * and $info is: 41 | * stdClass { 42 | * start: int, 43 | * end: int, 44 | * methods: [methodName => $methodInfo] 45 | * } 46 | * 47 | * where $methodInfo is: 48 | * stdClass { 49 | * start: int, 50 | * end: int, 51 | * visibility: public|protected|private 52 | * } 53 | */ 54 | public function parse(string $code): \stdClass 55 | { 56 | $tokens = \PhpToken::tokenize($code, TOKEN_PARSE); 57 | 58 | $level = $classLevel = $functionLevel = null; 59 | $namespace = ''; 60 | $line = 1; 61 | 62 | $result = (object) [ 63 | 'linesOfCode' => max(1, substr_count($code, "\n")), 64 | 'linesOfComments' => 0, 65 | 'functions' => [], 66 | 'classes' => [], 67 | 'traits' => [], 68 | 'interfaces' => [], 69 | ]; 70 | 71 | while ($token = current($tokens)) { 72 | next($tokens); 73 | $line = $token->line; 74 | 75 | switch ($token->id) { 76 | case T_NAMESPACE: 77 | $namespace = self::fetch($tokens, [T_STRING, T_NAME_QUALIFIED]); 78 | $namespace = ltrim($namespace . '\\', '\\'); 79 | break; 80 | 81 | case T_CLASS: 82 | case T_INTERFACE: 83 | case T_TRAIT: 84 | if ($name = self::fetch($tokens, T_STRING)) { 85 | if ($token->id === T_CLASS) { 86 | $class = &$result->classes[$namespace . $name]; 87 | } elseif ($token->id === T_INTERFACE) { 88 | $class = &$result->interfaces[$namespace . $name]; 89 | } else { 90 | $class = &$result->traits[$namespace . $name]; 91 | } 92 | 93 | $classLevel = $level + 1; 94 | $class = (object) [ 95 | 'start' => $line, 96 | 'end' => null, 97 | 'methods' => [], 98 | ]; 99 | } 100 | 101 | break; 102 | 103 | case T_PUBLIC: 104 | case T_PROTECTED: 105 | case T_PRIVATE: 106 | $visibility = $token->text; 107 | break; 108 | 109 | case T_ABSTRACT: 110 | $isAbstract = true; 111 | break; 112 | 113 | case T_FUNCTION: 114 | if (($name = self::fetch($tokens, T_STRING)) && !isset($isAbstract)) { 115 | if (isset($class) && $level === $classLevel) { 116 | $function = &$class->methods[$name]; 117 | $function = (object) [ 118 | 'start' => $line, 119 | 'end' => null, 120 | 'visibility' => $visibility ?? 'public', 121 | ]; 122 | 123 | } else { 124 | $function = &$result->functions[$namespace . $name]; 125 | $function = (object) [ 126 | 'start' => $line, 127 | 'end' => null, 128 | ]; 129 | } 130 | 131 | $functionLevel = $level + 1; 132 | } 133 | 134 | unset($visibility, $isAbstract); 135 | break; 136 | 137 | case T_CURLY_OPEN: 138 | case T_DOLLAR_OPEN_CURLY_BRACES: 139 | case ord('{'): 140 | $level++; 141 | break; 142 | 143 | case ord('}'): 144 | if (isset($function) && $level === $functionLevel) { 145 | $function->end = $line; 146 | unset($function); 147 | 148 | } elseif (isset($class) && $level === $classLevel) { 149 | $class->end = $line; 150 | unset($class); 151 | } 152 | 153 | $level--; 154 | break; 155 | 156 | case T_COMMENT: 157 | case T_DOC_COMMENT: 158 | $result->linesOfComments += substr_count(trim($token->text), "\n") + 1; 159 | // break omitted 160 | 161 | case T_WHITESPACE: 162 | case T_CONSTANT_ENCAPSED_STRING: 163 | $line += substr_count($token->text, "\n"); 164 | break; 165 | } 166 | } 167 | 168 | return $result; 169 | } 170 | 171 | 172 | private static function fetch(array &$tokens, array|int $take): ?string 173 | { 174 | $res = null; 175 | while ($token = current($tokens)) { 176 | if ($token->is($take)) { 177 | $res .= $token->text; 178 | } elseif (!$token->is([T_DOC_COMMENT, T_WHITESPACE, T_COMMENT])) { 179 | break; 180 | } 181 | 182 | next($tokens); 183 | } 184 | 185 | return $res; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/Framework/Assert.php: -------------------------------------------------------------------------------- 1 | '%', // one % character 24 | '%a%' => '[^\r\n]+', // one or more of anything except the end of line characters 25 | '%a\?%' => '[^\r\n]*', // zero or more of anything except the end of line characters 26 | '%A%' => '.+', // one or more of anything including the end of line characters 27 | '%A\?%' => '.*', // zero or more of anything including the end of line characters 28 | '%s%' => '[\t ]+', // one or more white space characters except the end of line characters 29 | '%s\?%' => '[\t ]*', // zero or more white space characters except the end of line characters 30 | '%S%' => '\S+', // one or more of characters except the white space 31 | '%S\?%' => '\S*', // zero or more of characters except the white space 32 | '%c%' => '[^\r\n]', // a single character of any sort (except the end of line) 33 | '%d%' => '[0-9]+', // one or more digits 34 | '%d\?%' => '[0-9]*', // zero or more digits 35 | '%i%' => '[+-]?[0-9]+', // signed integer value 36 | '%f%' => '[+-]?\.?\d+\.?\d*(?:[Ee][+-]?\d+)?', // floating point number 37 | '%h%' => '[0-9a-fA-F]+', // one or more HEX digits 38 | '%w%' => '[0-9a-zA-Z_]+', //one or more alphanumeric characters 39 | '%ds%' => '[\\\/]', // directory separator 40 | '%(\[.+\][+*?{},\d]*)%' => '$1', // range 41 | ]; 42 | 43 | /** expand patterns in match() and matchFile() */ 44 | public static bool $expandPatterns = true; 45 | 46 | /** @var callable function (AssertException $exception): void */ 47 | public static $onFailure; 48 | public static int $counter = 0; 49 | 50 | 51 | /** 52 | * Asserts that two values are equal and have the same type and identity of objects. 53 | */ 54 | public static function same(mixed $expected, mixed $actual, ?string $description = null): void 55 | { 56 | self::$counter++; 57 | if ($actual !== $expected) { 58 | self::fail(self::describe('%1 should be %2', $description), $actual, $expected); 59 | } 60 | } 61 | 62 | 63 | /** 64 | * Asserts that two values are not equal or do not have the same type and identity of objects. 65 | */ 66 | public static function notSame(mixed $expected, mixed $actual, ?string $description = null): void 67 | { 68 | self::$counter++; 69 | if ($actual === $expected) { 70 | self::fail(self::describe('%1 should not be %2', $description), $actual, $expected); 71 | } 72 | } 73 | 74 | 75 | /** 76 | * Asserts that two values are equal and checks expectations. The identity of objects, 77 | * the order of keys in the arrays and marginally different floats are ignored by default. 78 | */ 79 | public static function equal( 80 | mixed $expected, 81 | mixed $actual, 82 | ?string $description = null, 83 | bool $matchOrder = false, 84 | bool $matchIdentity = false, 85 | ): void 86 | { 87 | self::$counter++; 88 | if (!self::isEqual($expected, $actual, $matchOrder, $matchIdentity)) { 89 | self::fail(self::describe('%1 should be equal to %2', $description), $actual, $expected); 90 | } 91 | } 92 | 93 | 94 | /** 95 | * Asserts that two values are not equal and checks expectations. The identity of objects, 96 | * the order of keys in the arrays and marginally different floats are ignored. 97 | */ 98 | public static function notEqual(mixed $expected, mixed $actual, ?string $description = null): void 99 | { 100 | self::$counter++; 101 | try { 102 | $res = self::isEqual($expected, $actual, matchOrder: false, matchIdentity: false); 103 | } catch (AssertException $e) { 104 | } 105 | 106 | if (empty($e) && $res) { 107 | self::fail(self::describe('%1 should not be equal to %2', $description), $actual, $expected); 108 | } 109 | } 110 | 111 | 112 | /** 113 | * Asserts that a haystack (string or array) contains an expected needle. 114 | */ 115 | public static function contains(mixed $needle, array|string $actual, ?string $description = null): void 116 | { 117 | self::$counter++; 118 | if (is_array($actual)) { 119 | if (!in_array($needle, $actual, true)) { 120 | self::fail(self::describe('%1 should contain %2', $description), $actual, $needle); 121 | } 122 | } elseif (!is_string($needle)) { 123 | self::fail(self::describe('Needle %1 should be string'), $needle); 124 | 125 | } elseif ($needle !== '' && !str_contains($actual, $needle)) { 126 | self::fail(self::describe('%1 should contain %2', $description), $actual, $needle); 127 | } 128 | } 129 | 130 | 131 | /** 132 | * Asserts that a haystack (string or array) does not contain an expected needle. 133 | */ 134 | public static function notContains(mixed $needle, array|string $actual, ?string $description = null): void 135 | { 136 | self::$counter++; 137 | if (is_array($actual)) { 138 | if (in_array($needle, $actual, true)) { 139 | self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle); 140 | } 141 | } elseif (!is_string($needle)) { 142 | self::fail(self::describe('Needle %1 should be string'), $needle); 143 | 144 | } elseif ($needle === '' || str_contains($actual, $needle)) { 145 | self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle); 146 | } 147 | } 148 | 149 | 150 | /** 151 | * Asserts that a haystack has an expected key. 152 | */ 153 | public static function hasKey(string|int $key, array $actual, ?string $description = null): void 154 | { 155 | self::$counter++; 156 | if (!array_key_exists($key, $actual)) { 157 | self::fail(self::describe('%1 should contain key %2', $description), $actual, $key); 158 | } 159 | } 160 | 161 | 162 | /** 163 | * Asserts that a haystack doesn't have an expected key. 164 | */ 165 | public static function hasNotKey(string|int $key, array $actual, ?string $description = null): void 166 | { 167 | self::$counter++; 168 | if (array_key_exists($key, $actual)) { 169 | self::fail(self::describe('%1 should not contain key %2', $description), $actual, $key); 170 | } 171 | } 172 | 173 | 174 | /** 175 | * Asserts that a value is true. 176 | */ 177 | public static function true(mixed $actual, ?string $description = null): void 178 | { 179 | self::$counter++; 180 | if ($actual !== true) { 181 | self::fail(self::describe('%1 should be true', $description), $actual); 182 | } 183 | } 184 | 185 | 186 | /** 187 | * Asserts that a value is false. 188 | */ 189 | public static function false(mixed $actual, ?string $description = null): void 190 | { 191 | self::$counter++; 192 | if ($actual !== false) { 193 | self::fail(self::describe('%1 should be false', $description), $actual); 194 | } 195 | } 196 | 197 | 198 | /** 199 | * Asserts that a value is null. 200 | */ 201 | public static function null(mixed $actual, ?string $description = null): void 202 | { 203 | self::$counter++; 204 | if ($actual !== null) { 205 | self::fail(self::describe('%1 should be null', $description), $actual); 206 | } 207 | } 208 | 209 | 210 | /** 211 | * Asserts that a value is not null. 212 | */ 213 | public static function notNull(mixed $actual, ?string $description = null): void 214 | { 215 | self::$counter++; 216 | if ($actual === null) { 217 | self::fail(self::describe('Value should not be null', $description)); 218 | } 219 | } 220 | 221 | 222 | /** 223 | * Asserts that a value is Not a Number. 224 | */ 225 | public static function nan(mixed $actual, ?string $description = null): void 226 | { 227 | self::$counter++; 228 | if (!is_float($actual) || !is_nan($actual)) { 229 | self::fail(self::describe('%1 should be NAN', $description), $actual); 230 | } 231 | } 232 | 233 | 234 | /** 235 | * Asserts that a value is truthy. 236 | */ 237 | public static function truthy(mixed $actual, ?string $description = null): void 238 | { 239 | self::$counter++; 240 | if (!$actual) { 241 | self::fail(self::describe('%1 should be truthy', $description), $actual); 242 | } 243 | } 244 | 245 | 246 | /** 247 | * Asserts that a value is falsey. 248 | */ 249 | public static function falsey(mixed $actual, ?string $description = null): void 250 | { 251 | self::$counter++; 252 | if ($actual) { 253 | self::fail(self::describe('%1 should be falsey', $description), $actual); 254 | } 255 | } 256 | 257 | 258 | /** 259 | * Asserts the number of items in an array or Countable. 260 | */ 261 | public static function count(int $count, array|\Countable $value, ?string $description = null): void 262 | { 263 | self::$counter++; 264 | if (count($value) !== $count) { 265 | self::fail(self::describe('Count %1 should be %2', $description), count($value), $count); 266 | } 267 | } 268 | 269 | 270 | /** 271 | * Asserts that a value is of given class, interface or built-in type. 272 | */ 273 | public static function type(string|object $type, mixed $value, ?string $description = null): void 274 | { 275 | self::$counter++; 276 | if ($type === 'list') { 277 | if (!is_array($value) || ($value && array_keys($value) !== range(0, count($value) - 1))) { 278 | self::fail(self::describe("%1 should be $type", $description), $value); 279 | } 280 | } elseif (in_array($type, ['array', 'bool', 'callable', 'float', 281 | 'int', 'integer', 'null', 'object', 'resource', 'scalar', 'string', ], true) 282 | ) { 283 | if (!("is_$type")($value)) { 284 | self::fail(self::describe(get_debug_type($value) . " should be $type", $description)); 285 | } 286 | } elseif (!$value instanceof $type) { 287 | $actual = get_debug_type($value); 288 | $type = is_object($type) ? $type::class : $type; 289 | self::fail(self::describe("$actual should be instance of $type", $description)); 290 | } 291 | } 292 | 293 | 294 | /** 295 | * Asserts that a function throws exception of given type and its message matches given pattern. 296 | */ 297 | public static function exception( 298 | callable $function, 299 | string $class, 300 | ?string $message = null, 301 | $code = null, 302 | ): ?\Throwable 303 | { 304 | self::$counter++; 305 | $e = null; 306 | try { 307 | $function(); 308 | } catch (\Throwable $e) { 309 | } 310 | 311 | if ($e === null) { 312 | self::fail("$class was expected, but none was thrown"); 313 | 314 | } elseif (!$e instanceof $class) { 315 | self::fail("$class was expected but got " . $e::class . ($e->getMessage() ? " ({$e->getMessage()})" : ''), null, null, $e); 316 | 317 | } elseif ($message && !self::isMatching($message, $e->getMessage())) { 318 | self::fail("$class with a message matching %2 was expected but got %1", $e->getMessage(), $message, $e); 319 | 320 | } elseif ($code !== null && $e->getCode() !== $code) { 321 | self::fail("$class with a code %2 was expected but got %1", $e->getCode(), $code, $e); 322 | } 323 | 324 | return $e; 325 | } 326 | 327 | 328 | /** 329 | * Asserts that a function throws exception of given type and its message matches given pattern. Alias for exception(). 330 | */ 331 | public static function throws( 332 | callable $function, 333 | string $class, 334 | ?string $message = null, 335 | mixed $code = null, 336 | ): ?\Throwable 337 | { 338 | return self::exception($function, $class, $message, $code); 339 | } 340 | 341 | 342 | /** 343 | * Asserts that a function generates one or more PHP errors or throws exceptions. 344 | * @throws \Exception 345 | */ 346 | public static function error( 347 | callable $function, 348 | int|string|array $expectedType, 349 | ?string $expectedMessage = null, 350 | ): ?\Throwable 351 | { 352 | if (is_string($expectedType) && !preg_match('#^E_[A-Z_]+$#D', $expectedType)) { 353 | return static::exception($function, $expectedType, $expectedMessage); 354 | } 355 | 356 | self::$counter++; 357 | $expected = is_array($expectedType) ? $expectedType : [[$expectedType, $expectedMessage]]; 358 | foreach ($expected as &$item) { 359 | $item = ((array) $item) + [null, null]; 360 | $expectedType = $item[0]; 361 | if (is_int($expectedType)) { 362 | $item[2] = Helpers::errorTypeToString($expectedType); 363 | } elseif (is_string($expectedType)) { 364 | $item[0] = constant($item[2] = $expectedType); 365 | } else { 366 | throw new \Exception('Error type must be E_* constant.'); 367 | } 368 | } 369 | 370 | set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$expected) { 371 | if (($severity & error_reporting()) !== $severity) { 372 | return; 373 | } 374 | 375 | $errorStr = Helpers::errorTypeToString($severity) . ($message ? " ($message)" : ''); 376 | [$expectedType, $expectedMessage, $expectedTypeStr] = array_shift($expected); 377 | if ($expectedType === null) { 378 | self::fail("Generated more errors than expected: $errorStr was generated in file $file on line $line"); 379 | 380 | } elseif ($severity !== $expectedType) { 381 | self::fail("$expectedTypeStr was expected, but $errorStr was generated in file $file on line $line"); 382 | 383 | } elseif ($expectedMessage && !self::isMatching($expectedMessage, $message)) { 384 | self::fail("$expectedTypeStr with a message matching %2 was expected but got %1", $message, $expectedMessage); 385 | } 386 | }); 387 | 388 | reset($expected); 389 | try { 390 | $function(); 391 | restore_error_handler(); 392 | } catch (\Throwable $e) { 393 | restore_error_handler(); 394 | throw $e; 395 | } 396 | 397 | if ($expected) { 398 | self::fail('Error was expected, but was not generated'); 399 | } 400 | 401 | return null; 402 | } 403 | 404 | 405 | /** 406 | * Asserts that a function does not generate PHP errors and does not throw exceptions. 407 | */ 408 | public static function noError(callable $function): void 409 | { 410 | if (($count = func_num_args()) > 1) { 411 | throw new \Exception(__METHOD__ . "() expects 1 parameter, $count given."); 412 | } 413 | 414 | self::error($function, []); 415 | } 416 | 417 | 418 | /** 419 | * Asserts that a string matches a given pattern. 420 | * %a% one or more of anything except the end of line characters 421 | * %a?% zero or more of anything except the end of line characters 422 | * %A% one or more of anything including the end of line characters 423 | * %A?% zero or more of anything including the end of line characters 424 | * %s% one or more white space characters except the end of line characters 425 | * %s?% zero or more white space characters except the end of line characters 426 | * %S% one or more of characters except the white space 427 | * %S?% zero or more of characters except the white space 428 | * %c% a single character of any sort (except the end of line) 429 | * %d% one or more digits 430 | * %d?% zero or more digits 431 | * %i% signed integer value 432 | * %f% floating point number 433 | * %h% one or more HEX digits 434 | * @param string $pattern mask|regexp; only delimiters ~ and # are supported for regexp 435 | */ 436 | public static function match(string $pattern, string $actual, ?string $description = null): void 437 | { 438 | self::$counter++; 439 | if (!self::isMatching($pattern, $actual)) { 440 | if (self::$expandPatterns) { 441 | [$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual); 442 | } 443 | 444 | self::fail(self::describe('%1 should match %2', $description), $actual, $pattern); 445 | } 446 | } 447 | 448 | 449 | /** 450 | * Asserts that a string matches a given pattern stored in file. 451 | */ 452 | public static function matchFile(string $file, string $actual, ?string $description = null): void 453 | { 454 | self::$counter++; 455 | $pattern = @file_get_contents($file); // @ is escalated to exception 456 | if ($pattern === false) { 457 | throw new \Exception("Unable to read file '$file'."); 458 | 459 | } elseif (!self::isMatching($pattern, $actual)) { 460 | if (self::$expandPatterns) { 461 | [$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual); 462 | } 463 | 464 | self::fail(self::describe('%1 should match %2', $description), $actual, $pattern, null, basename($file)); 465 | } 466 | } 467 | 468 | 469 | /** 470 | * Assertion that fails. 471 | */ 472 | public static function fail( 473 | string $message, 474 | $actual = null, 475 | $expected = null, 476 | ?\Throwable $previous = null, 477 | ?string $outputName = null, 478 | ): void 479 | { 480 | $e = new AssertException($message, $expected, $actual, $previous); 481 | $e->outputName = $outputName; 482 | if (self::$onFailure) { 483 | (self::$onFailure)($e); 484 | } else { 485 | throw $e; 486 | } 487 | } 488 | 489 | 490 | private static function describe(string $reason, ?string $description = null): string 491 | { 492 | return ($description ? $description . ': ' : '') . $reason; 493 | } 494 | 495 | 496 | /** 497 | * Executes function that can access private and protected members of given object via $this. 498 | */ 499 | public static function with(object|string $objectOrClass, \Closure $closure): mixed 500 | { 501 | return $closure->bindTo(is_object($objectOrClass) ? $objectOrClass : null, $objectOrClass)(); 502 | } 503 | 504 | 505 | /********************* helpers ****************d*g**/ 506 | 507 | 508 | /** 509 | * Compares using mask. 510 | * @internal 511 | */ 512 | public static function isMatching(string $pattern, string $actual, bool $strict = false): bool 513 | { 514 | $old = ini_set('pcre.backtrack_limit', '10000000'); 515 | 516 | if (!self::isPcre($pattern)) { 517 | $utf8 = preg_match('#\x80-\x{10FFFF}]#u', $pattern) ? 'u' : ''; 518 | $suffix = ($strict ? '$#DsU' : '\s*$#sU') . $utf8; 519 | $patterns = static::$patterns + [ 520 | '[.\\\+*?[^$(){|\#]' => '\$0', // preg quoting 521 | '\x00' => '\x00', 522 | '[\t ]*\r?\n' => '[\t ]*\r?\n', // right trim 523 | ]; 524 | $pattern = '#^' . preg_replace_callback('#' . implode('|', array_keys($patterns)) . '#U' . $utf8, function ($m) use ($patterns) { 525 | foreach ($patterns as $re => $replacement) { 526 | $s = preg_replace("#^$re$#D", str_replace('\\', '\\\\', $replacement), $m[0], 1, $count); 527 | if ($count) { 528 | return $s; 529 | } 530 | } 531 | }, rtrim($pattern, " \t\n\r")) . $suffix; 532 | } 533 | 534 | $res = preg_match($pattern, (string) $actual); 535 | ini_set('pcre.backtrack_limit', $old); 536 | if ($res === false || preg_last_error()) { 537 | throw new \Exception('Error while executing regular expression. (PREG Error Code ' . preg_last_error() . ')'); 538 | } 539 | 540 | return (bool) $res; 541 | } 542 | 543 | 544 | /** 545 | * @internal 546 | */ 547 | public static function expandMatchingPatterns(string $pattern, string $actual): array 548 | { 549 | if (self::isPcre($pattern)) { 550 | return [$pattern, $actual]; 551 | } 552 | 553 | $parts = preg_split('#(%)#', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE); 554 | for ($i = count($parts); $i >= 0; $i--) { 555 | $patternX = implode('', array_slice($parts, 0, $i)); 556 | $patternY = "$patternX%A?%"; 557 | if (self::isMatching($patternY, $actual)) { 558 | $patternZ = implode('', array_slice($parts, $i)); 559 | break; 560 | } 561 | } 562 | 563 | foreach (['%A%', '%A?%'] as $greedyPattern) { 564 | if (substr($patternX, -strlen($greedyPattern)) === $greedyPattern) { 565 | $patternX = substr($patternX, 0, -strlen($greedyPattern)); 566 | $patternY = "$patternX%A?%"; 567 | $patternZ = $greedyPattern . $patternZ; 568 | break; 569 | } 570 | } 571 | 572 | $low = 0; 573 | $high = strlen($actual); 574 | while ($low <= $high) { 575 | $mid = ($low + $high) >> 1; 576 | if (self::isMatching($patternY, substr($actual, 0, $mid))) { 577 | $high = $mid - 1; 578 | } else { 579 | $low = $mid + 1; 580 | } 581 | } 582 | 583 | $low = $high + 2; 584 | $high = strlen($actual); 585 | while ($low <= $high) { 586 | $mid = ($low + $high) >> 1; 587 | if (!self::isMatching($patternX, substr($actual, 0, $mid), strict: true)) { 588 | $high = $mid - 1; 589 | } else { 590 | $low = $mid + 1; 591 | } 592 | } 593 | 594 | $actualX = substr($actual, 0, $high); 595 | $actualZ = substr($actual, $high); 596 | 597 | return [ 598 | $actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $patternZ)), 599 | $actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $actualZ)), 600 | ]; 601 | } 602 | 603 | 604 | /** 605 | * Compares two structures and checks expectations. The identity of objects, the order of keys 606 | * in the arrays and marginally different floats are ignored. 607 | */ 608 | private static function isEqual( 609 | mixed $expected, 610 | mixed $actual, 611 | bool $matchOrder, 612 | bool $matchIdentity, 613 | int $level = 0, 614 | ?\SplObjectStorage $objects = null, 615 | ): bool 616 | { 617 | switch (true) { 618 | case $level > 10: 619 | throw new \Exception('Nesting level too deep or recursive dependency.'); 620 | 621 | case $expected instanceof Expect: 622 | $expected($actual); 623 | return true; 624 | 625 | case is_float($expected) && is_float($actual) && is_finite($expected) && is_finite($actual): 626 | $diff = abs($expected - $actual); 627 | return ($diff < self::Epsilon) || ($diff / max(abs($expected), abs($actual)) < self::Epsilon); 628 | 629 | case !$matchIdentity && is_object($expected) && is_object($actual) && $expected::class === $actual::class: 630 | $objects = $objects ? clone $objects : new \SplObjectStorage; 631 | if (isset($objects[$expected])) { 632 | return $objects[$expected] === $actual; 633 | } elseif ($expected === $actual) { 634 | return true; 635 | } 636 | 637 | $objects[$expected] = $actual; 638 | $objects[$actual] = $expected; 639 | $expected = (array) $expected; 640 | $actual = (array) $actual; 641 | // break omitted 642 | 643 | case is_array($expected) && is_array($actual): 644 | if ($matchOrder) { 645 | reset($expected); 646 | reset($actual); 647 | } else { 648 | ksort($expected, SORT_STRING); 649 | ksort($actual, SORT_STRING); 650 | } 651 | 652 | if (array_keys($expected) !== array_keys($actual)) { 653 | return false; 654 | } 655 | 656 | foreach ($expected as $value) { 657 | if (!self::isEqual($value, current($actual), $matchOrder, $matchIdentity, $level + 1, $objects)) { 658 | return false; 659 | } 660 | 661 | next($actual); 662 | } 663 | 664 | return true; 665 | 666 | default: 667 | return $expected === $actual; 668 | } 669 | } 670 | 671 | 672 | private static function isPcre(string $pattern): bool 673 | { 674 | return (bool) preg_match('/^([~#]).+(\1)[imsxUu]*$/Ds', $pattern); 675 | } 676 | } 677 | -------------------------------------------------------------------------------- /src/Framework/AssertException.php: -------------------------------------------------------------------------------- 1 | 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 | -------------------------------------------------------------------------------- /src/Framework/DataProvider.php: -------------------------------------------------------------------------------- 1 | require func_get_arg(0))(realpath($file)); 30 | 31 | if ($data instanceof \Traversable) { 32 | $data = iterator_to_array($data); 33 | } elseif (!is_array($data)) { 34 | throw new \Exception("Data provider '$file' did not return array or Traversable."); 35 | } 36 | } else { 37 | $data = @parse_ini_file($file, true, INI_SCANNER_TYPED); // @ is escalated to exception 38 | if ($data === false) { 39 | throw new \Exception("Cannot parse data provider file '$file'."); 40 | } 41 | } 42 | 43 | foreach ($data as $key => $value) { 44 | if (!self::testQuery((string) $key, $query)) { 45 | unset($data[$key]); 46 | } 47 | } 48 | 49 | return $data; 50 | } 51 | 52 | 53 | /** 54 | * Evaluates a query against a set of data keys to determine if the key matches the criteria. 55 | */ 56 | public static function testQuery(string $input, string $query): bool 57 | { 58 | $replaces = ['' => '=', '=>' => '>=', '=<' => '<=']; 59 | $tokens = preg_split('#\s+#', $input); 60 | preg_match_all('#\s*,?\s*(<=|=<|<|==|=|!=|<>|>=|=>|>)?\s*([^\s,]+)#A', $query, $queryParts, PREG_SET_ORDER); 61 | foreach ($queryParts as [, $operator, $operand]) { 62 | $operator = $replaces[$operator] ?? $operator; 63 | $token = (string) array_shift($tokens); 64 | $res = preg_match('#^[0-9.]+$#D', $token) 65 | ? version_compare($token, $operand, $operator) 66 | : self::compare($token, $operator, $operand); 67 | if (!$res) { 68 | return false; 69 | } 70 | } 71 | 72 | return true; 73 | } 74 | 75 | 76 | /** 77 | * Compares two values using the specified operator. 78 | */ 79 | private static function compare(mixed $l, string $operator, mixed $r): bool 80 | { 81 | return match ($operator) { 82 | '>' => $l > $r, 83 | '>=', '=>' => $l >= $r, 84 | '<' => $l < $r, 85 | '=<', '<=' => $l <= $r, 86 | '=', '==' => $l == $r, 87 | '!', '!=', '<>' => $l != $r, 88 | default => throw new \InvalidArgumentException("Unknown operator '$operator'"), 89 | }; 90 | } 91 | 92 | 93 | /** 94 | * Parses a data provider annotation from a test method to extract the file path and query. 95 | */ 96 | public static function parseAnnotation(string $annotation, string $file): array 97 | { 98 | if (!preg_match('#^(\??)\s*([^,\s]+)\s*,?\s*(\S.*)?()#', $annotation, $m)) { 99 | throw new \Exception("Invalid @dataProvider value '$annotation'."); 100 | } 101 | 102 | return [dirname($file) . DIRECTORY_SEPARATOR . $m[2], $m[3], (bool) $m[1]]; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Framework/DomQuery.php: -------------------------------------------------------------------------------- 1 | ' . $html; 31 | } 32 | 33 | // parse these elements as void 34 | $html = preg_replace('#<(keygen|source|track|wbr)(?=\s|>)((?:"[^"]*"|\'[^\']*\'|[^"\'>])*+)(?#', '<$1$2 />', $html); 35 | 36 | // fix parsing of )(?:"[^"]*"|\'[^\']*\'|[^"\'>])*+>)(.*?)()#s', 39 | fn(array $m): string => $m[1] . str_replace('loadHTML($html); 45 | } else { 46 | if (!preg_match('~' . $html; 48 | } 49 | $dom = Dom\HTMLDocument::createFromString($html, Dom\HTML_NO_DEFAULT_NS); 50 | } 51 | 52 | $errors = libxml_get_errors(); 53 | libxml_use_internal_errors($old); 54 | 55 | foreach ($errors as $error) { 56 | if (!preg_match('#Tag \S+ invalid#', $error->message)) { 57 | trigger_error(__METHOD__ . ": $error->message on line $error->line.", E_USER_WARNING); 58 | } 59 | } 60 | 61 | return simplexml_import_dom($dom, self::class); 62 | } 63 | 64 | 65 | /** 66 | * Creates a DomQuery object from an XML string. 67 | */ 68 | public static function fromXml(string $xml): self 69 | { 70 | return simplexml_load_string($xml, self::class); 71 | } 72 | 73 | 74 | /** 75 | * Returns array of elements matching CSS selector. 76 | * @return DomQuery[] 77 | */ 78 | public function find(string $selector): array 79 | { 80 | if (PHP_VERSION_ID < 80400) { 81 | return str_starts_with($selector, ':scope') 82 | ? $this->xpath('self::' . self::css2xpath(substr($selector, 6))) 83 | : $this->xpath('descendant::' . self::css2xpath($selector)); 84 | } 85 | 86 | return array_map( 87 | fn($el) => simplexml_import_dom($el, self::class), 88 | iterator_to_array(Dom\import_simplexml($this)->querySelectorAll($selector)), 89 | ); 90 | } 91 | 92 | 93 | /** 94 | * Checks if any descendant matches CSS selector. 95 | */ 96 | public function has(string $selector): bool 97 | { 98 | return PHP_VERSION_ID < 80400 99 | ? (bool) $this->find($selector) 100 | : (bool) Dom\import_simplexml($this)->querySelector($selector); 101 | } 102 | 103 | 104 | /** 105 | * Checks if element matches CSS selector. 106 | */ 107 | public function matches(string $selector): bool 108 | { 109 | return PHP_VERSION_ID < 80400 110 | ? (bool) $this->xpath('self::' . self::css2xpath($selector)) 111 | : Dom\import_simplexml($this)->matches($selector); 112 | } 113 | 114 | 115 | /** 116 | * Returns closest ancestor matching CSS selector. 117 | */ 118 | public function closest(string $selector): ?self 119 | { 120 | if (PHP_VERSION_ID < 80400) { 121 | throw new \LogicException('Requires PHP 8.4 or newer.'); 122 | } 123 | $el = Dom\import_simplexml($this)->closest($selector); 124 | return $el ? simplexml_import_dom($el, self::class) : null; 125 | } 126 | 127 | 128 | /** 129 | * Converts a CSS selector into an XPath expression. 130 | */ 131 | public static function css2xpath(string $css): string 132 | { 133 | $xpath = '*'; 134 | preg_match_all(<<<'XX' 135 | / 136 | ([#.:]?)([a-z][a-z0-9_-]*)| # id, class, pseudoclass (1,2) 137 | \[ 138 | ([a-z0-9_-]+) 139 | (?: 140 | ([~*^$]?)=( 141 | "[^"]*"| 142 | '[^']*'| 143 | [^\]]+ 144 | ) 145 | )? 146 | \]| # [attr=val] (3,4,5) 147 | \s*([>,+~])\s*| # > , + ~ (6) 148 | (\s+)| # whitespace (7) 149 | (\*) # * (8) 150 | /ix 151 | XX, trim($css), $matches, PREG_SET_ORDER); 152 | foreach ($matches as $m) { 153 | if ($m[1] === '#') { // #ID 154 | $xpath .= "[@id='$m[2]']"; 155 | } elseif ($m[1] === '.') { // .class 156 | $xpath .= "[contains(concat(' ', normalize-space(@class), ' '), ' $m[2] ')]"; 157 | } elseif ($m[1] === ':') { // :pseudo-class 158 | throw new \InvalidArgumentException('Not implemented.'); 159 | } elseif ($m[2]) { // tag 160 | $xpath = rtrim($xpath, '*') . $m[2]; 161 | } elseif ($m[3]) { // [attribute] 162 | $attr = '@' . strtolower($m[3]); 163 | if (!isset($m[5])) { 164 | $xpath .= "[$attr]"; 165 | continue; 166 | } 167 | 168 | $val = trim($m[5], '"\''); 169 | if ($m[4] === '') { 170 | $xpath .= "[$attr='$val']"; 171 | } elseif ($m[4] === '~') { 172 | $xpath .= "[contains(concat(' ', normalize-space($attr), ' '), ' $val ')]"; 173 | } elseif ($m[4] === '*') { 174 | $xpath .= "[contains($attr, '$val')]"; 175 | } elseif ($m[4] === '^') { 176 | $xpath .= "[starts-with($attr, '$val')]"; 177 | } elseif ($m[4] === '$') { 178 | $xpath .= "[substring($attr, string-length($attr)-0)='$val']"; 179 | } 180 | } elseif ($m[6] === '>') { 181 | $xpath .= '/*'; 182 | } elseif ($m[6] === ',') { 183 | $xpath .= '|//*'; 184 | } elseif ($m[6] === '~') { 185 | $xpath .= '/following-sibling::*'; 186 | } elseif ($m[6] === '+') { 187 | throw new \InvalidArgumentException('Not implemented.'); 188 | } elseif ($m[7]) { 189 | $xpath .= '//*'; 190 | } 191 | } 192 | 193 | return $xpath; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Framework/Dumper.php: -------------------------------------------------------------------------------- 1 | self::$maxLength) { 47 | $var = substr($var, 0, self::$maxLength) . '...'; 48 | } 49 | 50 | return self::encodeStringLine($var); 51 | 52 | } elseif (is_array($var)) { 53 | $out = ''; 54 | $counter = 0; 55 | foreach ($var as $k => &$v) { 56 | $out .= ($out === '' ? '' : ', '); 57 | if (strlen($out) > self::$maxLength) { 58 | $out .= '...'; 59 | break; 60 | } 61 | 62 | $out .= ($k === $counter ? '' : self::toLine($k) . ' => ') 63 | . (is_array($v) && $v ? '[...]' : self::toLine($v)); 64 | $counter = is_int($k) ? max($k + 1, $counter) : $counter; 65 | } 66 | 67 | return "[$out]"; 68 | 69 | } elseif ($var instanceof \Throwable) { 70 | return 'Exception ' . $var::class . ': ' . ($var->getCode() ? '#' . $var->getCode() . ' ' : '') . $var->getMessage(); 71 | 72 | } elseif ($var instanceof Expect) { 73 | return $var->dump(); 74 | 75 | } elseif (is_object($var)) { 76 | return self::objectToLine($var); 77 | 78 | } elseif (is_resource($var)) { 79 | return 'resource(' . get_resource_type($var) . ')'; 80 | 81 | } else { 82 | return 'unknown type'; 83 | } 84 | } 85 | 86 | 87 | /** 88 | * Formats object to line. 89 | */ 90 | private static function objectToLine(object $object): string 91 | { 92 | $line = $object::class; 93 | if ($object instanceof \DateTime || $object instanceof \DateTimeInterface) { 94 | $line .= '(' . $object->format('Y-m-d H:i:s O') . ')'; 95 | } 96 | 97 | return $line . '(' . self::hash($object) . ')'; 98 | } 99 | 100 | 101 | /** 102 | * Dumps variable in PHP format. 103 | */ 104 | public static function toPhp(mixed $var): string 105 | { 106 | return self::_toPhp($var); 107 | } 108 | 109 | 110 | /** 111 | * Returns object's stripped hash. 112 | */ 113 | private static function hash(object $object): string 114 | { 115 | return '#' . substr(md5(spl_object_hash($object)), 0, 4); 116 | } 117 | 118 | 119 | private static function _toPhp(mixed &$var, array &$list = [], int $level = 0, int &$line = 1): string 120 | { 121 | if (is_float($var)) { 122 | $var = str_replace(',', '.', "$var"); 123 | return !str_contains($var, '.') ? $var . '.0' : $var; 124 | 125 | } elseif (is_bool($var)) { 126 | return $var ? 'true' : 'false'; 127 | 128 | } elseif ($var === null) { 129 | return 'null'; 130 | 131 | } elseif (is_string($var)) { 132 | $res = self::encodeStringPhp($var); 133 | $line += substr_count($res, "\n"); 134 | return $res; 135 | 136 | } elseif (is_array($var)) { 137 | $space = str_repeat("\t", $level); 138 | 139 | static $marker; 140 | if ($marker === null) { 141 | $marker = uniqid("\x00", more_entropy: true); 142 | } 143 | 144 | if (empty($var)) { 145 | $out = ''; 146 | 147 | } elseif ($level > self::$maxDepth || isset($var[$marker])) { 148 | return '/* Nesting level too deep or recursive dependency */'; 149 | 150 | } else { 151 | $out = "\n$space"; 152 | $outShort = ''; 153 | $var[$marker] = true; 154 | $oldLine = $line; 155 | $line++; 156 | $counter = 0; 157 | foreach ($var as $k => &$v) { 158 | if ($k !== $marker) { 159 | $item = ($k === $counter ? '' : self::_toPhp($k, $list, $level + 1, $line) . ' => ') . self::_toPhp($v, $list, $level + 1, $line); 160 | $counter = is_int($k) ? max($k + 1, $counter) : $counter; 161 | $outShort .= ($outShort === '' ? '' : ', ') . $item; 162 | $out .= "\t$item,\n$space"; 163 | $line++; 164 | } 165 | } 166 | 167 | unset($var[$marker]); 168 | if (!str_contains($outShort, "\n") && strlen($outShort) < self::$maxLength) { 169 | $line = $oldLine; 170 | $out = $outShort; 171 | } 172 | } 173 | 174 | return '[' . $out . ']'; 175 | 176 | } elseif ($var instanceof \Closure) { 177 | $rc = new \ReflectionFunction($var); 178 | return "/* Closure defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */"; 179 | 180 | } elseif ($var instanceof \UnitEnum) { 181 | return $var::class . '::' . $var->name; 182 | 183 | } elseif (is_object($var)) { 184 | if (($rc = new \ReflectionObject($var))->isAnonymous()) { 185 | return "/* Anonymous class defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */"; 186 | } 187 | 188 | $arr = (array) $var; 189 | $space = str_repeat("\t", $level); 190 | $class = $var::class; 191 | $used = &$list[spl_object_hash($var)]; 192 | 193 | if (empty($arr)) { 194 | $out = ''; 195 | 196 | } elseif ($used) { 197 | return "/* $class dumped on line $used */"; 198 | 199 | } elseif ($level > self::$maxDepth) { 200 | return '/* Nesting level too deep */'; 201 | 202 | } else { 203 | $out = "\n"; 204 | $used = $line; 205 | $line++; 206 | foreach ($arr as $k => &$v) { 207 | if (isset($k[0]) && $k[0] === "\x00") { 208 | $k = substr($k, strrpos($k, "\x00") + 1); 209 | } 210 | 211 | $out .= "$space\t" . self::_toPhp($k, $list, $level + 1, $line) . ' => ' . self::_toPhp($v, $list, $level + 1, $line) . ",\n"; 212 | $line++; 213 | } 214 | 215 | $out .= $space; 216 | } 217 | 218 | $hash = self::hash($var); 219 | return $class === 'stdClass' 220 | ? "(object) /* $hash */ [$out]" 221 | : "$class::__set_state(/* $hash */ [$out])"; 222 | 223 | } elseif (is_resource($var)) { 224 | return '/* resource ' . get_resource_type($var) . ' */'; 225 | 226 | } else { 227 | return var_export($var, return: true); 228 | } 229 | } 230 | 231 | 232 | private static function encodeStringPhp(string $s): string 233 | { 234 | $special = [ 235 | "\r" => '\r', 236 | "\n" => '\n', 237 | "\t" => "\t", 238 | "\e" => '\e', 239 | '\\' => '\\\\', 240 | ]; 241 | $utf8 = preg_match('##u', $s); 242 | $escaped = preg_replace_callback( 243 | $utf8 ? '#[\p{C}\\\]#u' : '#[\x00-\x1F\x7F-\xFF\\\]#', 244 | fn($m) => $special[$m[0]] ?? (strlen($m[0]) === 1 245 | ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) . '' 246 | : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}'), 247 | $s, 248 | ); 249 | return $s === str_replace('\\\\', '\\', $escaped) 250 | ? "'" . preg_replace('#\'|\\\(?=[\'\\\]|$)#D', '\\\$0', $s) . "'" 251 | : '"' . addcslashes($escaped, '"$') . '"'; 252 | } 253 | 254 | 255 | private static function encodeStringLine(string $s): string 256 | { 257 | $special = [ 258 | "\r" => "\\r\r", 259 | "\n" => "\\n\n", 260 | "\t" => "\\t\t", 261 | "\e" => '\e', 262 | "'" => "'", 263 | ]; 264 | $utf8 = preg_match('##u', $s); 265 | $escaped = preg_replace_callback( 266 | $utf8 ? '#[\p{C}\']#u' : '#[\x00-\x1F\x7F-\xFF\']#', 267 | fn($m) => "\e[22m" 268 | . ($special[$m[0]] ?? (strlen($m[0]) === 1 269 | ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) 270 | : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}')) 271 | . "\e[1m", 272 | $s, 273 | ); 274 | return "'" . $escaped . "'"; 275 | } 276 | 277 | 278 | private static function utf8Ord(string $c): int 279 | { 280 | $ord0 = ord($c[0]); 281 | if ($ord0 < 0x80) { 282 | return $ord0; 283 | } elseif ($ord0 < 0xE0) { 284 | return ($ord0 << 6) + ord($c[1]) - 0x3080; 285 | } elseif ($ord0 < 0xF0) { 286 | return ($ord0 << 12) + (ord($c[1]) << 6) + ord($c[2]) - 0xE2080; 287 | } else { 288 | return ($ord0 << 18) + (ord($c[1]) << 12) + (ord($c[2]) << 6) + ord($c[3]) - 0x3C82080; 289 | } 290 | } 291 | 292 | 293 | public static function dumpException(\Throwable $e): string 294 | { 295 | $trace = $e->getTrace(); 296 | array_splice($trace, 0, $e instanceof \ErrorException ? 1 : 0, [['file' => $e->getFile(), 'line' => $e->getLine()]]); 297 | 298 | $testFile = null; 299 | foreach (array_reverse($trace) as $item) { 300 | if (isset($item['file'])) { // in case of shutdown handler, we want to skip inner-code blocks and debugging calls 301 | $testFile = $item['file']; 302 | break; 303 | } 304 | } 305 | 306 | if ($e instanceof AssertException) { 307 | $expected = $e->expected; 308 | $actual = $e->actual; 309 | $testFile = $e->outputName 310 | ? dirname($testFile) . '/' . $e->outputName . '.foo' 311 | : $testFile; 312 | 313 | if (is_object($expected) || is_array($expected) || (is_string($expected) && strlen($expected) > self::$maxLength) 314 | || is_object($actual) || is_array($actual) || (is_string($actual) && (strlen($actual) > self::$maxLength || preg_match('#[\x00-\x1F]#', $actual))) 315 | ) { 316 | $args = isset($_SERVER['argv'][1]) 317 | ? '.[' . implode(' ', preg_replace(['#^-*([^|]+).*#i', '#[^=a-z0-9. -]+#i'], ['$1', '-'], array_slice($_SERVER['argv'], 1))) . ']' 318 | : ''; 319 | $stored[] = self::saveOutput($testFile, $expected, $args . '.expected'); 320 | $stored[] = self::saveOutput($testFile, $actual, $args . '.actual'); 321 | } 322 | 323 | if ((is_string($actual) && is_string($expected))) { 324 | for ($i = 0; $i < strlen($actual) && isset($expected[$i]) && $actual[$i] === $expected[$i]; $i++); 325 | for (; $i && $i < strlen($actual) && $actual[$i - 1] >= "\x80" && $actual[$i] >= "\x80" && $actual[$i] < "\xC0"; $i--); 326 | $i = max(0, min( 327 | $i - (int) (self::$maxLength / 3), // try to display 1/3 of shorter string 328 | max(strlen($actual), strlen($expected)) - self::$maxLength + 3, // 3 = length of ... 329 | )); 330 | if ($i) { 331 | $expected = substr_replace($expected, '...', 0, $i); 332 | $actual = substr_replace($actual, '...', 0, $i); 333 | } 334 | } 335 | 336 | $message = 'Failed: ' . $e->origMessage; 337 | if (((is_string($actual) && is_string($expected)) || (is_array($actual) && is_array($expected))) 338 | && preg_match('#^(.*)(%\d)(.*)(%\d.*)$#Ds', $message, $m) 339 | ) { 340 | $message = ($delta = strlen($m[1]) - strlen($m[3])) >= 3 341 | ? "$m[1]$m[2]\n" . str_repeat(' ', $delta - 3) . "...$m[3]$m[4]" 342 | : "$m[1]$m[2]$m[3]\n" . str_repeat(' ', strlen($m[1]) - 4) . "... $m[4]"; 343 | } 344 | 345 | $message = strtr($message, [ 346 | '%1' => self::color('yellow') . self::toLine($actual) . self::color('white'), 347 | '%2' => self::color('yellow') . self::toLine($expected) . self::color('white'), 348 | ]); 349 | } else { 350 | $message = ($e instanceof \ErrorException ? Helpers::errorTypeToString($e->getSeverity()) : $e::class) 351 | . ': ' . preg_replace('#[\x00-\x09\x0B-\x1F]+#', ' ', $e->getMessage()); 352 | } 353 | 354 | $s = self::color('white', $message) . "\n\n" 355 | . (isset($stored) ? 'diff ' . Helpers::escapeArg($stored[0]) . ' ' . Helpers::escapeArg($stored[1]) . "\n\n" : ''); 356 | 357 | foreach ($trace as $item) { 358 | $item += ['file' => null, 'class' => null, 'type' => null, 'function' => null]; 359 | if ($e instanceof AssertException && $item['file'] === __DIR__ . DIRECTORY_SEPARATOR . 'Assert.php') { 360 | continue; 361 | } 362 | 363 | $line = $item['class'] === Assert::class && method_exists($item['class'], $item['function']) 364 | && strpos($tmp = file($item['file'])[$item['line'] - 1], "::$item[function](") ? $tmp : null; 365 | 366 | $s .= 'in ' 367 | . ($item['file'] 368 | ? ( 369 | ($item['file'] === $testFile ? self::color('white') : '') 370 | . implode( 371 | self::$pathSeparator ?? DIRECTORY_SEPARATOR, 372 | array_slice(explode(DIRECTORY_SEPARATOR, $item['file']), -self::$maxPathSegments), 373 | ) 374 | . "($item[line])" . self::color('gray') . ' ' 375 | ) 376 | : '[internal function]' 377 | ) 378 | . ($line 379 | ? trim($line) 380 | : $item['class'] . $item['type'] . $item['function'] . ($item['function'] ? '()' : '') 381 | ) 382 | . self::color() . "\n"; 383 | } 384 | 385 | if ($e->getPrevious()) { 386 | $s .= "\n(previous) " . static::dumpException($e->getPrevious()); 387 | } 388 | 389 | return $s; 390 | } 391 | 392 | 393 | /** 394 | * Dumps data to folder 'output'. 395 | */ 396 | public static function saveOutput(string $testFile, mixed $content, string $suffix = ''): string 397 | { 398 | $path = self::$dumpDir . DIRECTORY_SEPARATOR . pathinfo($testFile, PATHINFO_FILENAME) . $suffix; 399 | if (!preg_match('#/|\w:#A', self::$dumpDir)) { 400 | $path = dirname($testFile) . DIRECTORY_SEPARATOR . $path; 401 | } 402 | 403 | @mkdir(dirname($path)); // @ - directory may already exist 404 | file_put_contents($path, is_string($content) ? $content : (self::toPhp($content) . "\n")); 405 | return $path; 406 | } 407 | 408 | 409 | /** 410 | * Applies color to string. 411 | */ 412 | public static function color(string $color = '', ?string $s = null): string 413 | { 414 | $colors = [ 415 | 'black' => '0;30', 'gray' => '1;30', 'silver' => '0;37', 'white' => '1;37', 416 | 'navy' => '0;34', 'blue' => '1;34', 'green' => '0;32', 'lime' => '1;32', 417 | 'teal' => '0;36', 'aqua' => '1;36', 'maroon' => '0;31', 'red' => '1;31', 418 | 'purple' => '0;35', 'fuchsia' => '1;35', 'olive' => '0;33', 'yellow' => '1;33', 419 | null => '0', 420 | ]; 421 | $c = explode('/', $color); 422 | return "\e[" 423 | . str_replace(';', "m\e[", $colors[$c[0]] . (empty($c[1]) ? '' : ';4' . substr($colors[$c[1]], -1))) 424 | . 'm' . $s . ($s === null ? '' : "\e[0m"); 425 | } 426 | 427 | 428 | public static function removeColors(string $s): string 429 | { 430 | return preg_replace('#\e\[[\d;]+m#', '', $s); 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /src/Framework/Environment.php: -------------------------------------------------------------------------------- 1 | self::$useColors ? $s : Dumper::removeColors($s), 96 | 1, 97 | PHP_OUTPUT_HANDLER_FLUSHABLE, 98 | ); 99 | } 100 | 101 | 102 | /** 103 | * Configures PHP error handling. 104 | */ 105 | public static function setupErrors(): void 106 | { 107 | error_reporting(E_ALL); 108 | ini_set('display_errors', '1'); 109 | ini_set('html_errors', '0'); 110 | ini_set('log_errors', '0'); 111 | 112 | set_exception_handler([self::class, 'handleException']); 113 | 114 | set_error_handler(function (int $severity, string $message, string $file, int $line): ?bool { 115 | if ( 116 | in_array($severity, [E_RECOVERABLE_ERROR, E_USER_ERROR], true) 117 | || ($severity & error_reporting()) === $severity 118 | ) { 119 | self::handleException(new \ErrorException($message, 0, $severity, $file, $line)); 120 | } 121 | 122 | return false; 123 | }); 124 | 125 | register_shutdown_function(function (): void { 126 | Assert::$onFailure = [self::class, 'handleException']; 127 | 128 | $error = error_get_last(); 129 | register_shutdown_function(function () use ($error): void { 130 | if (in_array($error['type'] ?? null, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) { 131 | if (($error['type'] & error_reporting()) !== $error['type']) { // show fatal errors hidden by @shutup 132 | self::print("\n" . Dumper::color('white/red', "Fatal error: $error[message] in $error[file] on line $error[line]")); 133 | } 134 | } elseif (self::$checkAssertions && !Assert::$counter) { 135 | self::print("\n" . Dumper::color('white/red', 'Error: This test forgets to execute an assertion.')); 136 | self::exit(Runner\Job::CodeFail); 137 | } elseif (!getenv(self::VariableRunner) && self::$exitCode !== Runner\Job::CodeSkip) { 138 | self::print("\n" . (self::$exitCode ? Dumper::color('white/red', 'FAILURE') : Dumper::color('white/green', 'OK'))); 139 | } 140 | }); 141 | }); 142 | } 143 | 144 | 145 | /** 146 | * Creates global functions test(), testException(), setUp() and tearDown(). 147 | */ 148 | public static function setupFunctions(): void 149 | { 150 | require __DIR__ . '/functions.php'; 151 | } 152 | 153 | 154 | /** 155 | * @internal 156 | */ 157 | public static function handleException(\Throwable $e): void 158 | { 159 | self::$checkAssertions = false; 160 | self::print(Dumper::dumpException($e)); 161 | self::exit($e instanceof AssertException ? Runner\Job::CodeFail : Runner\Job::CodeError); 162 | } 163 | 164 | 165 | /** 166 | * Skips this test. 167 | */ 168 | public static function skip(string $message = ''): void 169 | { 170 | self::$checkAssertions = false; 171 | self::print("\nSkipped:\n$message"); 172 | self::exit(Runner\Job::CodeSkip); 173 | } 174 | 175 | 176 | /** 177 | * Locks the parallel tests. 178 | * @param string $path lock store directory 179 | */ 180 | public static function lock(string $name = '', string $path = ''): void 181 | { 182 | static $locks; 183 | $file = "$path/lock-" . md5($name); 184 | if (!isset($locks[$file])) { 185 | flock($locks[$file] = fopen($file, 'w'), LOCK_EX); 186 | } 187 | } 188 | 189 | 190 | /** 191 | * Returns current test annotations. 192 | */ 193 | public static function getTestAnnotations(): array 194 | { 195 | $trace = debug_backtrace(); 196 | return ($file = $trace[count($trace) - 1]['file'] ?? null) 197 | ? Helpers::parseDocComment(file_get_contents($file)) + ['file' => $file] 198 | : []; 199 | } 200 | 201 | 202 | /** 203 | * Removes keyword final from source codes. 204 | */ 205 | public static function bypassFinals(): void 206 | { 207 | FileMutator::addMutator(function (string $code): string { 208 | if (str_contains($code, 'final')) { 209 | $tokens = \PhpToken::tokenize($code, TOKEN_PARSE); 210 | $code = ''; 211 | foreach ($tokens as $token) { 212 | $code .= $token->is(T_FINAL) ? '' : $token->text; 213 | } 214 | } 215 | 216 | return $code; 217 | }); 218 | } 219 | 220 | 221 | /** 222 | * Loads data according to the file annotation or specified by Tester\Runner\TestHandler::initiateDataProvider() 223 | */ 224 | public static function loadData(): array 225 | { 226 | if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--dataprovider=(.*)#Ai', '$1', $_SERVER['argv']))) { 227 | [$key, $file] = explode('|', reset($tmp), 2); 228 | $data = DataProvider::load($file); 229 | if (!array_key_exists($key, $data)) { 230 | throw new \Exception("Missing dataset '$key' from data provider '$file'."); 231 | } 232 | 233 | return $data[$key]; 234 | } 235 | 236 | $annotations = self::getTestAnnotations(); 237 | if (!isset($annotations['dataprovider'])) { 238 | throw new \Exception('Missing annotation @dataProvider.'); 239 | } 240 | 241 | $provider = (array) $annotations['dataprovider']; 242 | [$file, $query] = DataProvider::parseAnnotation($provider[0], $annotations['file']); 243 | 244 | $data = DataProvider::load($file, $query); 245 | if (!$data) { 246 | throw new \Exception("No datasets from data provider '$file'" . ($query ? " for query '$query'" : '') . '.'); 247 | } 248 | 249 | return reset($data); 250 | } 251 | 252 | 253 | public static function exit(int $code = 0): void 254 | { 255 | self::$exitCode = $code; 256 | exit($code); 257 | } 258 | 259 | 260 | /** @internal */ 261 | public static function print(string $s): void 262 | { 263 | $s = $s === '' || str_ends_with($s, "\n") ? $s : $s . "\n"; 264 | if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { 265 | fwrite(STDOUT, self::$useColors ? $s : Dumper::removeColors($s)); 266 | } else { 267 | echo $s; 268 | } 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/Framework/Expect.php: -------------------------------------------------------------------------------- 1 | */ 53 | private array $constraints = []; 54 | 55 | 56 | public static function __callStatic(string $method, array $args): self 57 | { 58 | $me = new self; 59 | $me->constraints[] = (object) ['method' => $method, 'args' => $args]; 60 | return $me; 61 | } 62 | 63 | 64 | public static function that(callable $constraint): self 65 | { 66 | return (new self)->and($constraint); 67 | } 68 | 69 | 70 | public function __call(string $method, array $args): self 71 | { 72 | if (preg_match('#^and([A-Z]\w+)#', $method, $m)) { 73 | $this->constraints[] = (object) ['method' => lcfirst($m[1]), 'args' => $args]; 74 | return $this; 75 | } 76 | 77 | throw new \Error('Call to undefined method ' . self::class . '::' . $method . '()'); 78 | } 79 | 80 | 81 | public function and(callable $constraint): self 82 | { 83 | $this->constraints[] = $constraint; 84 | return $this; 85 | } 86 | 87 | 88 | /** 89 | * Checks the expectations. 90 | */ 91 | public function __invoke(mixed $actual): void 92 | { 93 | foreach ($this->constraints as $cstr) { 94 | if ($cstr instanceof \stdClass) { 95 | $args = $cstr->args; 96 | $args[] = $actual; 97 | Assert::{$cstr->method}(...$args); 98 | 99 | } elseif ($cstr($actual) === false) { 100 | Assert::fail('%1 is expected to be %2', $actual, is_string($cstr) ? $cstr : 'user-expectation'); 101 | } 102 | } 103 | } 104 | 105 | 106 | public function dump(): string 107 | { 108 | $res = []; 109 | foreach ($this->constraints as $cstr) { 110 | if ($cstr instanceof \stdClass) { 111 | $args = isset($cstr->args[0]) 112 | ? Dumper::toLine($cstr->args[0]) 113 | : ''; 114 | $res[] = "$cstr->method($args)"; 115 | 116 | } elseif ($cstr instanceof self) { 117 | $res[] = $cstr->dump(); 118 | 119 | } else { 120 | $res[] = is_string($cstr) ? $cstr : 'user-expectation'; 121 | } 122 | } 123 | 124 | return implode(',', $res); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Framework/FileMock.php: -------------------------------------------------------------------------------- 1 | warning("failed to open stream: Invalid mode '$mode'"); 62 | return false; 63 | 64 | } elseif ($m[1] === 'x' && isset(self::$files[$path])) { 65 | $this->warning('failed to open stream: File exists'); 66 | return false; 67 | 68 | } elseif ($m[1] === 'r' && !isset(self::$files[$path])) { 69 | $this->warning('failed to open stream: No such file or directory'); 70 | return false; 71 | 72 | } elseif ($m[1] === 'w' || $m[1] === 'x') { 73 | self::$files[$path] = ''; 74 | } 75 | 76 | $tmp = &self::$files[$path]; 77 | $tmp = (string) $tmp; 78 | $this->content = &$tmp; 79 | $this->appendMode = $m[1] === 'a'; 80 | $this->readingPos = 0; 81 | $this->writingPos = $this->appendMode ? strlen($this->content) : 0; 82 | $this->isReadable = isset($m[2]) || $m[1] === 'r'; 83 | $this->isWritable = isset($m[2]) || $m[1] !== 'r'; 84 | 85 | return true; 86 | } 87 | 88 | 89 | public function stream_read(int $length) 90 | { 91 | if (!$this->isReadable) { 92 | return false; 93 | } 94 | 95 | $result = substr($this->content, $this->readingPos, $length); 96 | $this->readingPos += strlen($result); 97 | $this->writingPos += $this->appendMode ? 0 : strlen($result); 98 | return $result; 99 | } 100 | 101 | 102 | public function stream_write(string $data) 103 | { 104 | if (!$this->isWritable) { 105 | return false; 106 | } 107 | 108 | $length = strlen($data); 109 | $this->content = str_pad($this->content, $this->writingPos, "\x00"); 110 | $this->content = substr_replace($this->content, $data, $this->writingPos, $length); 111 | $this->readingPos += $length; 112 | $this->writingPos += $length; 113 | return $length; 114 | } 115 | 116 | 117 | public function stream_tell(): int 118 | { 119 | return $this->readingPos; 120 | } 121 | 122 | 123 | public function stream_eof(): bool 124 | { 125 | return $this->readingPos >= strlen($this->content); 126 | } 127 | 128 | 129 | public function stream_seek(int $offset, int $whence): bool 130 | { 131 | if ($whence === SEEK_CUR) { 132 | $offset += $this->readingPos; 133 | } elseif ($whence === SEEK_END) { 134 | $offset += strlen($this->content); 135 | } 136 | 137 | if ($offset >= 0) { 138 | $this->readingPos = $offset; 139 | $this->writingPos = $this->appendMode ? $this->writingPos : $offset; 140 | return true; 141 | } else { 142 | return false; 143 | } 144 | } 145 | 146 | 147 | public function stream_truncate(int $size): bool 148 | { 149 | if (!$this->isWritable) { 150 | return false; 151 | } 152 | 153 | $this->content = substr(str_pad($this->content, $size, "\x00"), 0, $size); 154 | $this->writingPos = $this->appendMode ? $size : $this->writingPos; 155 | return true; 156 | } 157 | 158 | 159 | public function stream_set_option(int $option, int $arg1, int $arg2): bool 160 | { 161 | return false; 162 | } 163 | 164 | 165 | public function stream_stat(): array 166 | { 167 | return ['mode' => 0100666, 'size' => strlen($this->content)]; 168 | } 169 | 170 | 171 | public function url_stat(string $path, int $flags) 172 | { 173 | return isset(self::$files[$path]) 174 | ? ['mode' => 0100666, 'size' => strlen(self::$files[$path])] 175 | : false; 176 | } 177 | 178 | 179 | public function stream_lock(int $operation): bool 180 | { 181 | return false; 182 | } 183 | 184 | 185 | public function stream_metadata(string $path, int $option, $value): bool 186 | { 187 | switch ($option) { 188 | case STREAM_META_TOUCH: 189 | return true; 190 | } 191 | 192 | return false; 193 | } 194 | 195 | 196 | public function unlink(string $path): bool 197 | { 198 | if (isset(self::$files[$path])) { 199 | unset(self::$files[$path]); 200 | return true; 201 | } 202 | 203 | $this->warning('No such file'); 204 | return false; 205 | } 206 | 207 | 208 | private function warning(string $message): void 209 | { 210 | $bt = debug_backtrace(0, 3); 211 | if (isset($bt[2]['function'])) { 212 | $message = $bt[2]['function'] . '(' . @$bt[2]['args'][0] . '): ' . $message; 213 | } 214 | 215 | trigger_error($message, E_USER_WARNING); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/Framework/FileMutator.php: -------------------------------------------------------------------------------- 1 | handle); 42 | } 43 | 44 | 45 | public function dir_opendir(string $path, int $options): bool 46 | { 47 | $this->handle = $this->context 48 | ? $this->native('opendir', $path, $this->context) 49 | : $this->native('opendir', $path); 50 | return (bool) $this->handle; 51 | } 52 | 53 | 54 | public function dir_readdir() 55 | { 56 | return readdir($this->handle); 57 | } 58 | 59 | 60 | public function dir_rewinddir(): bool 61 | { 62 | return (bool) rewinddir($this->handle); 63 | } 64 | 65 | 66 | public function mkdir(string $path, int $mode, int $options): bool 67 | { 68 | $recursive = (bool) ($options & STREAM_MKDIR_RECURSIVE); 69 | return $this->context 70 | ? $this->native('mkdir', $path, $mode, $recursive, $this->context) 71 | : $this->native('mkdir', $path, $mode, $recursive); 72 | } 73 | 74 | 75 | public function rename(string $pathFrom, string $pathTo): bool 76 | { 77 | return $this->context 78 | ? $this->native('rename', $pathFrom, $pathTo, $this->context) 79 | : $this->native('rename', $pathFrom, $pathTo); 80 | } 81 | 82 | 83 | public function rmdir(string $path, int $options): bool 84 | { 85 | return $this->context 86 | ? $this->native('rmdir', $path, $this->context) 87 | : $this->native('rmdir', $path); 88 | } 89 | 90 | 91 | public function stream_cast(int $castAs) 92 | { 93 | return $this->handle; 94 | } 95 | 96 | 97 | public function stream_close(): void 98 | { 99 | fclose($this->handle); 100 | } 101 | 102 | 103 | public function stream_eof(): bool 104 | { 105 | return feof($this->handle); 106 | } 107 | 108 | 109 | public function stream_flush(): bool 110 | { 111 | return fflush($this->handle); 112 | } 113 | 114 | 115 | public function stream_lock(int $operation): bool 116 | { 117 | return $operation 118 | ? flock($this->handle, $operation) 119 | : true; 120 | } 121 | 122 | 123 | public function stream_metadata(string $path, int $option, $value): bool 124 | { 125 | switch ($option) { 126 | case STREAM_META_TOUCH: 127 | return $this->native('touch', $path, $value[0] ?? time(), $value[1] ?? time()); 128 | case STREAM_META_OWNER_NAME: 129 | case STREAM_META_OWNER: 130 | return $this->native('chown', $path, $value); 131 | case STREAM_META_GROUP_NAME: 132 | case STREAM_META_GROUP: 133 | return $this->native('chgrp', $path, $value); 134 | case STREAM_META_ACCESS: 135 | return $this->native('chmod', $path, $value); 136 | } 137 | 138 | return false; 139 | } 140 | 141 | 142 | public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool 143 | { 144 | $usePath = (bool) ($options & STREAM_USE_PATH); 145 | if ($mode === 'rb' && pathinfo($path, PATHINFO_EXTENSION) === 'php') { 146 | $content = $this->native('file_get_contents', $path, $usePath, $this->context); 147 | if ($content === false) { 148 | return false; 149 | } else { 150 | foreach (self::$mutators as $mutator) { 151 | $content = $mutator($content); 152 | } 153 | 154 | $this->handle = tmpfile(); 155 | $this->native('fwrite', $this->handle, $content); 156 | $this->native('fseek', $this->handle, 0); 157 | return true; 158 | } 159 | } else { 160 | $this->handle = $this->context 161 | ? $this->native('fopen', $path, $mode, $usePath, $this->context) 162 | : $this->native('fopen', $path, $mode, $usePath); 163 | return (bool) $this->handle; 164 | } 165 | } 166 | 167 | 168 | public function stream_read(int $count) 169 | { 170 | return fread($this->handle, $count); 171 | } 172 | 173 | 174 | public function stream_seek(int $offset, int $whence = SEEK_SET): bool 175 | { 176 | return fseek($this->handle, $offset, $whence) === 0; 177 | } 178 | 179 | 180 | public function stream_set_option(int $option, int $arg1, int $arg2): bool 181 | { 182 | return false; 183 | } 184 | 185 | 186 | public function stream_stat() 187 | { 188 | return fstat($this->handle); 189 | } 190 | 191 | 192 | public function stream_tell(): int 193 | { 194 | return ftell($this->handle); 195 | } 196 | 197 | 198 | public function stream_truncate(int $newSize): bool 199 | { 200 | return ftruncate($this->handle, $newSize); 201 | } 202 | 203 | 204 | public function stream_write(string $data) 205 | { 206 | return fwrite($this->handle, $data); 207 | } 208 | 209 | 210 | public function unlink(string $path): bool 211 | { 212 | return $this->native('unlink', $path); 213 | } 214 | 215 | 216 | public function url_stat(string $path, int $flags) 217 | { 218 | $func = $flags & STREAM_URL_STAT_LINK ? 'lstat' : 'stat'; 219 | return $flags & STREAM_URL_STAT_QUIET 220 | ? @$this->native($func, $path) 221 | : $this->native($func, $path); 222 | } 223 | 224 | 225 | private function native(string $func) 226 | { 227 | stream_wrapper_restore(self::Protocol); 228 | try { 229 | return $func(...array_slice(func_get_args(), 1)); 230 | } finally { 231 | stream_wrapper_unregister(self::Protocol); 232 | stream_wrapper_register(self::Protocol, self::class); 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/Framework/Helpers.php: -------------------------------------------------------------------------------- 1 | isDir()) { 33 | rmdir((string) $entry); 34 | } else { 35 | unlink((string) $entry); 36 | } 37 | } 38 | } 39 | 40 | 41 | /** 42 | * Find common directory for given paths. All files or directories must exist. 43 | * @return string Empty when not found. Slash and back slash chars normalized to DIRECTORY_SEPARATOR. 44 | * @internal 45 | */ 46 | public static function findCommonDirectory(array $paths): string 47 | { 48 | $splitPaths = array_map(function ($s) { 49 | $real = realpath($s); 50 | if ($s === '') { 51 | throw new \RuntimeException('Path must not be empty.'); 52 | } elseif ($real === false) { 53 | throw new \RuntimeException("File or directory '$s' does not exist."); 54 | } 55 | 56 | return explode(DIRECTORY_SEPARATOR, $real); 57 | }, $paths); 58 | 59 | $first = (array) array_shift($splitPaths); 60 | for ($i = 0; $i < count($first); $i++) { 61 | foreach ($splitPaths as $s) { 62 | if ($first[$i] !== ($s[$i] ?? null)) { 63 | break 2; 64 | } 65 | } 66 | } 67 | 68 | $common = implode(DIRECTORY_SEPARATOR, array_slice($first, 0, $i)); 69 | return is_dir($common) ? $common : dirname($common); 70 | } 71 | 72 | 73 | /** 74 | * Parse the first docblock encountered in the provided string. 75 | * @internal 76 | */ 77 | public static function parseDocComment(string $s): array 78 | { 79 | $options = []; 80 | if (!preg_match('#^/\*\*(.*?)\*/#ms', $s, $content)) { 81 | return []; 82 | } 83 | 84 | if (preg_match('#^[ \t\*]*+([^\s@].*)#mi', $content[1], $matches)) { 85 | $options[0] = trim($matches[1]); 86 | } 87 | 88 | preg_match_all('#^[ \t\*]*@(\w+)([^\w\r\n].*)?#mi', $content[1], $matches, PREG_SET_ORDER); 89 | foreach ($matches as $match) { 90 | $ref = &$options[strtolower($match[1])]; 91 | if (isset($ref)) { 92 | $ref = (array) $ref; 93 | $ref = &$ref[]; 94 | } 95 | 96 | $ref = isset($match[2]) ? trim($match[2]) : ''; 97 | } 98 | 99 | return $options; 100 | } 101 | 102 | 103 | /** 104 | * @internal 105 | */ 106 | public static function errorTypeToString(int $type): string 107 | { 108 | $consts = get_defined_constants(true); 109 | foreach ($consts['Core'] as $name => $val) { 110 | if ($type === $val && substr($name, 0, 2) === 'E_') { 111 | return $name; 112 | } 113 | } 114 | 115 | return 'Unknown error'; 116 | } 117 | 118 | 119 | /** 120 | * Escape a string to be used as a shell argument. 121 | * @internal 122 | */ 123 | public static function escapeArg(string $s): string 124 | { 125 | if (preg_match('#^[a-z0-9._=/:-]+$#Di', $s)) { 126 | return $s; 127 | } 128 | 129 | return defined('PHP_WINDOWS_VERSION_BUILD') 130 | ? '"' . str_replace('"', '""', $s) . '"' 131 | : escapeshellarg($s); 132 | } 133 | 134 | 135 | /** 136 | * @internal 137 | */ 138 | public static function prepareTempDir(string $path): string 139 | { 140 | $real = realpath($path); 141 | if ($real === false || !is_dir($real) || !is_writable($real)) { 142 | throw new \RuntimeException("Path '$real' is not a writable directory."); 143 | } 144 | 145 | $path = $real . DIRECTORY_SEPARATOR . 'Tester'; 146 | if (!is_dir($path) && @mkdir($path) === false && !is_dir($path)) { // @ - directory may exist 147 | throw new \RuntimeException("Cannot create '$path' directory."); 148 | } 149 | 150 | return $path; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Framework/TestCase.php: -------------------------------------------------------------------------------- 1 | $rm->getName(), (new \ReflectionObject($this))->getMethods()), 41 | )); 42 | 43 | if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--method=([\w-]+)$#Ai', '$1', $_SERVER['argv']))) { 44 | $method = reset($tmp); 45 | if ($method === self::ListMethods) { 46 | $this->sendMethodList($methods); 47 | return; 48 | } 49 | 50 | try { 51 | $this->runTest($method); 52 | } catch (TestCaseSkippedException $e) { 53 | Environment::skip($e->getMessage()); 54 | } 55 | } else { 56 | foreach ($methods as $method) { 57 | try { 58 | $this->runTest($method); 59 | Environment::print(Dumper::color('lime', '√') . " $method"); 60 | } catch (TestCaseSkippedException $e) { 61 | Environment::print("s $method {$e->getMessage()}"); 62 | } catch (\Throwable $e) { 63 | Environment::print(Dumper::color('red', '×') . " $method\n\n"); 64 | throw $e; 65 | } 66 | } 67 | } 68 | } 69 | 70 | 71 | /** 72 | * Executes a specified test method within this test case, handling data providers and errors. 73 | * @param ?array $args arguments provided for the test method, bypassing data provider if provided. 74 | */ 75 | public function runTest(string $method, ?array $args = null): void 76 | { 77 | if (!method_exists($this, $method)) { 78 | throw new TestCaseException("Method '$method' does not exist."); 79 | } elseif (!preg_match(self::MethodPattern, $method)) { 80 | throw new TestCaseException("Method '$method' is not a testing method."); 81 | } 82 | 83 | $method = new \ReflectionMethod($this, $method); 84 | if (!$method->isPublic()) { 85 | throw new TestCaseException("Method {$method->getName()} is not public. Make it public or rename it."); 86 | } 87 | 88 | $info = Helpers::parseDocComment((string) $method->getDocComment()) + ['throws' => null]; 89 | 90 | if ($info['throws'] === '') { 91 | throw new TestCaseException("Missing class name in @throws annotation for {$method->getName()}()."); 92 | } elseif (is_array($info['throws'])) { 93 | throw new TestCaseException("Annotation @throws for {$method->getName()}() can be specified only once."); 94 | } else { 95 | $throws = is_string($info['throws']) ? preg_split('#\s+#', $info['throws'], 2) : []; 96 | } 97 | 98 | $data = $args === null 99 | ? $this->prepareTestData($method, (array) ($info['dataprovider'] ?? [])) 100 | : [$args]; 101 | 102 | if ($this->prevErrorHandler === false) { 103 | $this->prevErrorHandler = set_error_handler(function (int $severity): ?bool { 104 | if ($this->handleErrors && ($severity & error_reporting()) === $severity) { 105 | $this->handleErrors = false; 106 | $this->silentTearDown(); 107 | } 108 | 109 | return $this->prevErrorHandler 110 | ? ($this->prevErrorHandler)(...func_get_args()) 111 | : false; 112 | }); 113 | } 114 | 115 | foreach ($data as $k => $params) { 116 | try { 117 | $this->setUp(); 118 | 119 | $this->handleErrors = true; 120 | $params = array_values($params); 121 | try { 122 | if ($info['throws']) { 123 | $e = Assert::error(function () use ($method, $params): void { 124 | [$this, $method->getName()](...$params); 125 | }, ...$throws); 126 | if ($e instanceof AssertException) { 127 | throw $e; 128 | } 129 | } else { 130 | [$this, $method->getName()](...$params); 131 | } 132 | } catch (\Throwable $e) { 133 | $this->handleErrors = false; 134 | $this->silentTearDown(); 135 | throw $e; 136 | } 137 | 138 | $this->handleErrors = false; 139 | 140 | $this->tearDown(); 141 | 142 | } catch (AssertException $e) { 143 | throw $e->setMessage(sprintf( 144 | '%s in %s(%s)%s', 145 | $e->origMessage, 146 | $method->getName(), 147 | substr(Dumper::toLine($params), 1, -1), 148 | is_string($k) ? (" (data set '" . explode('-', $k, 2)[1] . "')") : '', 149 | )); 150 | } 151 | } 152 | } 153 | 154 | 155 | protected function getData(string $provider) 156 | { 157 | if (!str_contains($provider, '.')) { 158 | return $this->$provider(); 159 | } else { 160 | $rc = new \ReflectionClass($this); 161 | [$file, $query] = DataProvider::parseAnnotation($provider, $rc->getFileName()); 162 | return DataProvider::load($file, $query); 163 | } 164 | } 165 | 166 | 167 | /** 168 | * Setup logic to be executed before each test method. Override in subclasses for specific behaviors. 169 | * @return void 170 | */ 171 | protected function setUp() 172 | { 173 | } 174 | 175 | 176 | /** 177 | * Teardown logic to be executed after each test method. Override in subclasses to release resources. 178 | * @return void 179 | */ 180 | protected function tearDown() 181 | { 182 | } 183 | 184 | 185 | /** 186 | * Executes the tearDown method and suppresses any errors, ensuring clean teardown in all cases. 187 | */ 188 | private function silentTearDown(): void 189 | { 190 | set_error_handler(fn() => null); 191 | try { 192 | $this->tearDown(); 193 | } catch (\Throwable $e) { 194 | } 195 | 196 | restore_error_handler(); 197 | } 198 | 199 | 200 | /** 201 | * Skips the current test, optionally providing a reason for skipping. 202 | */ 203 | protected function skip(string $message = ''): void 204 | { 205 | throw new TestCaseSkippedException($message); 206 | } 207 | 208 | 209 | /** 210 | * Outputs a list of all test methods in the current test case. Used for Runner. 211 | */ 212 | private function sendMethodList(array $methods): void 213 | { 214 | Environment::$checkAssertions = false; 215 | header('Content-Type: text/plain'); 216 | echo "\n"; 217 | echo 'TestCase:' . get_debug_type($this) . "\n"; 218 | echo 'Method:' . implode("\nMethod:", $methods) . "\n"; 219 | 220 | $dependentFiles = []; 221 | $reflections = [new \ReflectionObject($this)]; 222 | while (count($reflections)) { 223 | $rc = array_shift($reflections); 224 | $dependentFiles[$rc->getFileName()] = 1; 225 | 226 | if ($rpc = $rc->getParentClass()) { 227 | $reflections[] = $rpc; 228 | } 229 | 230 | foreach ($rc->getTraits() as $rt) { 231 | $reflections[] = $rt; 232 | } 233 | } 234 | 235 | echo 'Dependency:' . implode("\nDependency:", array_keys($dependentFiles)) . "\n"; 236 | } 237 | 238 | 239 | /** 240 | * Prepares test data from specified data providers or default method parameters if no provider is specified. 241 | */ 242 | private function prepareTestData(\ReflectionMethod $method, array $dataprovider): array 243 | { 244 | $data = $defaultParams = []; 245 | 246 | foreach ($method->getParameters() as $param) { 247 | $defaultParams[$param->getName()] = $param->isDefaultValueAvailable() 248 | ? $param->getDefaultValue() 249 | : null; 250 | } 251 | 252 | foreach ($dataprovider as $i => $provider) { 253 | $res = $this->getData($provider); 254 | if (!is_array($res) && !$res instanceof \Traversable) { 255 | throw new TestCaseException("Data provider $provider() doesn't return array or Traversable."); 256 | } 257 | 258 | foreach ($res as $k => $set) { 259 | if (!is_array($set)) { 260 | $type = get_debug_type($set); 261 | throw new TestCaseException("Data provider $provider() item '$k' must be an array, $type given."); 262 | } 263 | 264 | $data["$i-$k"] = is_string(key($set)) 265 | ? array_merge($defaultParams, $set) 266 | : $set; 267 | } 268 | } 269 | 270 | if (!$dataprovider) { 271 | if ($method->getNumberOfRequiredParameters()) { 272 | throw new TestCaseException("Method {$method->getName()}() has arguments, but @dataProvider is missing."); 273 | } 274 | 275 | $data[] = []; 276 | } 277 | 278 | return $data; 279 | } 280 | } 281 | 282 | /** 283 | * Errors specific to TestCase operations. 284 | */ 285 | class TestCaseException extends \Exception 286 | { 287 | } 288 | 289 | /** 290 | * Exception thrown when a test case or a test method is skipped. 291 | */ 292 | class TestCaseSkippedException extends \Exception 293 | { 294 | } 295 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Runner/CliTester.php: -------------------------------------------------------------------------------- 1 | setupErrors(); 33 | 34 | ob_start(); 35 | $cmd = $this->loadOptions(); 36 | 37 | $this->debugMode = (bool) $this->options['--debug']; 38 | if (isset($this->options['--colors'])) { 39 | Environment::$useColors = (bool) $this->options['--colors']; 40 | } elseif (in_array($this->stdoutFormat, ['tap', 'junit'], true)) { 41 | Environment::$useColors = false; 42 | } 43 | 44 | if ($cmd->isEmpty() || $this->options['--help']) { 45 | $cmd->help(); 46 | return 0; 47 | } 48 | 49 | $this->createPhpInterpreter(); 50 | 51 | if ($this->options['--info']) { 52 | $job = new Job(new Test(__DIR__ . '/info.php'), $this->interpreter); 53 | $job->setTempDirectory($this->options['--temp']); 54 | $job->run(); 55 | echo $job->getTest()->stdout; 56 | return 0; 57 | } 58 | 59 | $runner = $this->createRunner(); 60 | $runner->setEnvironmentVariable(Environment::VariableRunner, '1'); 61 | $runner->setEnvironmentVariable(Environment::VariableColors, (string) (int) Environment::$useColors); 62 | 63 | $this->installInterruptHandler(); 64 | 65 | if ($this->options['--coverage']) { 66 | $coverageFile = $this->prepareCodeCoverage($runner); 67 | } 68 | 69 | if ($this->stdoutFormat !== null) { 70 | ob_clean(); 71 | } 72 | 73 | ob_end_flush(); 74 | 75 | if ($this->options['--watch']) { 76 | $this->watch($runner); 77 | return 0; 78 | } 79 | 80 | $result = $runner->run(); 81 | 82 | if (isset($coverageFile) && preg_match('#\.(?:html?|xml)$#D', $coverageFile)) { 83 | $this->finishCodeCoverage($coverageFile); 84 | } 85 | 86 | return $result ? 0 : 1; 87 | } 88 | 89 | 90 | private function loadOptions(): CommandLine 91 | { 92 | $outputFiles = []; 93 | 94 | echo <<<'XX' 95 | _____ ___ ___ _____ ___ ___ 96 | |_ _/ __)( __/_ _/ __)| _ ) 97 | |_| \___ /___) |_| \___ |_|_\ v2.5.4 98 | 99 | 100 | XX; 101 | 102 | $cmd = new CommandLine( 103 | <<<'XX' 104 | Usage: 105 | tester [options] [ | ]... 106 | 107 | Options: 108 | -p Specify PHP interpreter to run (default: php). 109 | -c Look for php.ini file (or look in directory) . 110 | -C Use system-wide php.ini. 111 | -d ... Define INI entry 'key' with value 'value'. 112 | -s Show information about skipped tests. 113 | --stop-on-fail Stop execution upon the first failure. 114 | -j Run jobs in parallel (default: 8). 115 | -o (e.g. -o junit:output.xml) 116 | Specify one or more output formats with optional file name. 117 | -w | --watch Watch directory. 118 | -i | --info Show tests environment info and exit. 119 | --setup Script for runner setup. 120 | --temp Path to temporary directory. Default by sys_get_temp_dir(). 121 | --colors [1|0] Enable or disable colors. 122 | --coverage Generate code coverage report to file. 123 | --coverage-src Path to source code. 124 | -h | --help This help. 125 | 126 | XX, 127 | [ 128 | '-c' => [CommandLine::RealPath => true], 129 | '--watch' => [CommandLine::Repeatable => true, CommandLine::RealPath => true], 130 | '--setup' => [CommandLine::RealPath => true], 131 | '--temp' => [], 132 | 'paths' => [CommandLine::Repeatable => true, CommandLine::Value => getcwd()], 133 | '--debug' => [], 134 | '--cider' => [], 135 | '--coverage-src' => [CommandLine::RealPath => true, CommandLine::Repeatable => true], 136 | '-o' => [CommandLine::Repeatable => true, CommandLine::Normalizer => function ($arg) use (&$outputFiles) { 137 | [$format, $file] = explode(':', $arg, 2) + [1 => null]; 138 | 139 | if (isset($outputFiles[$file])) { 140 | throw new \Exception( 141 | $file === null 142 | ? 'Option -o without file name parameter can be used only once.' 143 | : "Cannot specify output by -o into file '$file' more then once.", 144 | ); 145 | } elseif ($file === null) { 146 | $this->stdoutFormat = $format; 147 | } 148 | 149 | $outputFiles[$file] = true; 150 | 151 | return [$format, $file]; 152 | }], 153 | ], 154 | ); 155 | 156 | if (isset($_SERVER['argv'])) { 157 | if (($tmp = array_search('-l', $_SERVER['argv'], strict: true)) 158 | || ($tmp = array_search('-log', $_SERVER['argv'], strict: true)) 159 | || ($tmp = array_search('--log', $_SERVER['argv'], strict: true)) 160 | ) { 161 | $_SERVER['argv'][$tmp] = '-o'; 162 | $_SERVER['argv'][$tmp + 1] = 'log:' . $_SERVER['argv'][$tmp + 1]; 163 | } 164 | 165 | if ($tmp = array_search('--tap', $_SERVER['argv'], strict: true)) { 166 | unset($_SERVER['argv'][$tmp]); 167 | $_SERVER['argv'] = array_merge($_SERVER['argv'], ['-o', 'tap']); 168 | } 169 | } 170 | 171 | $this->options = $cmd->parse(); 172 | if ($this->options['--temp'] === null) { 173 | if (($temp = sys_get_temp_dir()) === '') { 174 | echo "Note: System temporary directory is not set.\n"; 175 | } elseif (($real = realpath($temp)) === false) { 176 | echo "Note: System temporary directory '$temp' does not exist.\n"; 177 | } else { 178 | $this->options['--temp'] = Helpers::prepareTempDir($real); 179 | } 180 | } else { 181 | $this->options['--temp'] = Helpers::prepareTempDir($this->options['--temp']); 182 | } 183 | 184 | return $cmd; 185 | } 186 | 187 | 188 | private function createPhpInterpreter(): void 189 | { 190 | $args = $this->options['-C'] ? [] : ['-n']; 191 | if ($this->options['-c']) { 192 | array_push($args, '-c', $this->options['-c']); 193 | } elseif (!$this->options['--info'] && !$this->options['-C']) { 194 | echo "Note: No php.ini is used.\n"; 195 | } 196 | 197 | if (in_array($this->stdoutFormat, ['tap', 'junit'], true)) { 198 | array_push($args, '-d', 'html_errors=off'); 199 | } 200 | 201 | foreach ($this->options['-d'] as $item) { 202 | array_push($args, '-d', $item); 203 | } 204 | 205 | $this->interpreter = new PhpInterpreter($this->options['-p'], $args); 206 | 207 | if ($error = $this->interpreter->getStartupError()) { 208 | echo Dumper::color('red', "PHP startup error: $error") . "\n"; 209 | } 210 | } 211 | 212 | 213 | private function createRunner(): Runner 214 | { 215 | $runner = new Runner($this->interpreter); 216 | $runner->paths = $this->options['paths']; 217 | $runner->threadCount = max(1, (int) $this->options['-j']); 218 | $runner->stopOnFail = (bool) $this->options['--stop-on-fail']; 219 | $runner->setTempDirectory($this->options['--temp']); 220 | 221 | if ($this->stdoutFormat === null) { 222 | $runner->outputHandlers[] = new Output\ConsolePrinter( 223 | $runner, 224 | (bool) $this->options['-s'], 225 | 'php://output', 226 | (bool) $this->options['--cider'], 227 | ); 228 | } 229 | 230 | foreach ($this->options['-o'] as $output) { 231 | [$format, $file] = $output; 232 | match ($format) { 233 | 'console', 'console-lines' => $runner->outputHandlers[] = new Output\ConsolePrinter( 234 | $runner, 235 | (bool) $this->options['-s'], 236 | $file, 237 | (bool) $this->options['--cider'], 238 | $format === 'console-lines', 239 | ), 240 | 'tap' => $runner->outputHandlers[] = new Output\TapPrinter($file), 241 | 'junit' => $runner->outputHandlers[] = new Output\JUnitPrinter($file), 242 | 'log' => $runner->outputHandlers[] = new Output\Logger($runner, $file), 243 | 'none' => null, 244 | default => throw new \LogicException("Undefined output printer '$format'.'"), 245 | }; 246 | } 247 | 248 | if ($this->options['--setup']) { 249 | (function () use ($runner): void { 250 | require func_get_arg(0); 251 | })($this->options['--setup']); 252 | } 253 | 254 | return $runner; 255 | } 256 | 257 | 258 | private function prepareCodeCoverage(Runner $runner): string 259 | { 260 | $engines = $this->interpreter->getCodeCoverageEngines(); 261 | if (count($engines) < 1) { 262 | throw new \Exception("Code coverage functionality requires Xdebug or PCOV extension or PHPDBG SAPI (used {$this->interpreter->getCommandLine()})"); 263 | } 264 | 265 | file_put_contents($this->options['--coverage'], ''); 266 | $file = realpath($this->options['--coverage']); 267 | 268 | [$engine, $version] = reset($engines); 269 | 270 | $runner->setEnvironmentVariable(Environment::VariableCoverage, $file); 271 | $runner->setEnvironmentVariable(Environment::VariableCoverageEngine, $engine); 272 | 273 | if ($engine === CodeCoverage\Collector::EngineXdebug && version_compare($version, '3.0.0', '>=')) { 274 | $runner->addPhpIniOption('xdebug.mode', ltrim(ini_get('xdebug.mode') . ',coverage', ',')); 275 | } 276 | 277 | if ($engine === CodeCoverage\Collector::EnginePcov && count($this->options['--coverage-src'])) { 278 | $runner->addPhpIniOption('pcov.directory', Helpers::findCommonDirectory($this->options['--coverage-src'])); 279 | } 280 | 281 | echo "Code coverage by $engine: $file\n"; 282 | return $file; 283 | } 284 | 285 | 286 | private function finishCodeCoverage(string $file): void 287 | { 288 | if (!in_array($this->stdoutFormat, ['none', 'tap', 'junit'], true)) { 289 | echo 'Generating code coverage report... '; 290 | } 291 | 292 | if (filesize($file) === 0) { 293 | echo 'failed. Coverage file is empty. Do you call Tester\Environment::setup() in tests?' . "\n"; 294 | return; 295 | } 296 | 297 | $generator = pathinfo($file, PATHINFO_EXTENSION) === 'xml' 298 | ? new CodeCoverage\Generators\CloverXMLGenerator($file, $this->options['--coverage-src']) 299 | : new CodeCoverage\Generators\HtmlGenerator($file, $this->options['--coverage-src']); 300 | $generator->render($file); 301 | echo round($generator->getCoveredPercent()) . "% covered\n"; 302 | } 303 | 304 | 305 | private function watch(Runner $runner): void 306 | { 307 | $prev = []; 308 | $counter = 0; 309 | while (true) { 310 | $state = []; 311 | foreach ($this->options['--watch'] as $directory) { 312 | foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) as $file) { 313 | if (substr($file->getExtension(), 0, 3) === 'php' && substr($file->getBasename(), 0, 1) !== '.') { 314 | $state[(string) $file] = @filemtime((string) $file); // @ file could be deleted in the meantime 315 | } 316 | } 317 | } 318 | 319 | if ($state !== $prev) { 320 | $prev = $state; 321 | try { 322 | $runner->run(); 323 | } catch (\ErrorException $e) { 324 | $this->displayException($e); 325 | } 326 | 327 | echo "\n"; 328 | $time = time(); 329 | } 330 | 331 | $idle = time() - $time; 332 | if ($idle >= 60 * 60) { 333 | $idle = 'long time'; 334 | } elseif ($idle >= 60) { 335 | $idle = round($idle / 60) . ' min'; 336 | } else { 337 | $idle .= ' sec'; 338 | } 339 | 340 | echo 'Watching ' . implode(', ', $this->options['--watch']) . " (idle for $idle) " . str_repeat('.', ++$counter % 5) . " \r"; 341 | sleep(2); 342 | } 343 | } 344 | 345 | 346 | private function setupErrors(): void 347 | { 348 | error_reporting(E_ALL); 349 | ini_set('html_errors', '0'); 350 | 351 | set_error_handler(function (int $severity, string $message, string $file, int $line) { 352 | if (($severity & error_reporting()) === $severity) { 353 | throw new \ErrorException($message, 0, $severity, $file, $line); 354 | } 355 | 356 | return false; 357 | }); 358 | 359 | set_exception_handler(function (\Throwable $e) { 360 | if (!$e instanceof InterruptException) { 361 | $this->displayException($e); 362 | } 363 | 364 | exit(2); 365 | }); 366 | } 367 | 368 | 369 | private function displayException(\Throwable $e): void 370 | { 371 | echo "\n"; 372 | echo $this->debugMode 373 | ? Dumper::dumpException($e) 374 | : Dumper::color('white/red', 'Error: ' . $e->getMessage()); 375 | echo "\n"; 376 | } 377 | 378 | 379 | private function installInterruptHandler(): void 380 | { 381 | if (function_exists('pcntl_signal')) { 382 | pcntl_signal(SIGINT, function (): void { 383 | pcntl_signal(SIGINT, SIG_DFL); 384 | throw new InterruptException; 385 | }); 386 | pcntl_async_signals(true); 387 | 388 | } elseif (function_exists('sapi_windows_set_ctrl_handler') && PHP_SAPI === 'cli') { 389 | sapi_windows_set_ctrl_handler(function (): void { 390 | throw new InterruptException; 391 | }); 392 | } 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /src/Runner/CommandLine.php: -------------------------------------------------------------------------------- 1 | help = $help; 41 | $this->options = $defaults; 42 | 43 | preg_match_all('#^[ \t]+(--?\w.*?)(?: .*\(default: (.*)\)| |\r|$)#m', $help, $lines, PREG_SET_ORDER); 44 | foreach ($lines as $line) { 45 | preg_match_all('#(--?\w[\w-]*)(?:[= ](<.*?>|\[.*?]|\w+)(\.{0,3}))?[ ,|]*#A', $line[1], $m); 46 | if (!count($m[0]) || count($m[0]) > 2 || implode('', $m[0]) !== $line[1]) { 47 | throw new \InvalidArgumentException("Unable to parse '$line[1]'."); 48 | } 49 | 50 | $name = end($m[1]); 51 | $opts = $this->options[$name] ?? []; 52 | $this->options[$name] = $opts + [ 53 | self::Argument => (bool) end($m[2]), 54 | self::Optional => isset($line[2]) || (substr(end($m[2]), 0, 1) === '[') || isset($opts[self::Value]), 55 | self::Repeatable => (bool) end($m[3]), 56 | self::Enum => count($enums = explode('|', trim(end($m[2]), '<[]>'))) > 1 ? $enums : null, 57 | self::Value => $line[2] ?? null, 58 | ]; 59 | if ($name !== $m[1][0]) { 60 | $this->aliases[$m[1][0]] = $name; 61 | } 62 | } 63 | 64 | foreach ($this->options as $name => $foo) { 65 | if ($name[0] !== '-') { 66 | $this->positional[] = $name; 67 | } 68 | } 69 | } 70 | 71 | 72 | public function parse(?array $args = null): array 73 | { 74 | if ($args === null) { 75 | $args = isset($_SERVER['argv']) ? array_slice($_SERVER['argv'], 1) : []; 76 | } 77 | 78 | $params = []; 79 | reset($this->positional); 80 | $i = 0; 81 | while ($i < count($args)) { 82 | $arg = $args[$i++]; 83 | if ($arg[0] !== '-') { 84 | if (!current($this->positional)) { 85 | throw new \Exception("Unexpected parameter $arg."); 86 | } 87 | 88 | $name = current($this->positional); 89 | $this->checkArg($this->options[$name], $arg); 90 | if (empty($this->options[$name][self::Repeatable])) { 91 | $params[$name] = $arg; 92 | next($this->positional); 93 | } else { 94 | $params[$name][] = $arg; 95 | } 96 | 97 | continue; 98 | } 99 | 100 | [$name, $arg] = strpos($arg, '=') ? explode('=', $arg, 2) : [$arg, true]; 101 | 102 | if (isset($this->aliases[$name])) { 103 | $name = $this->aliases[$name]; 104 | 105 | } elseif (!isset($this->options[$name])) { 106 | throw new \Exception("Unknown option $name."); 107 | } 108 | 109 | $opt = $this->options[$name]; 110 | 111 | if ($arg !== true && empty($opt[self::Argument])) { 112 | throw new \Exception("Option $name has not argument."); 113 | 114 | } elseif ($arg === true && !empty($opt[self::Argument])) { 115 | if (isset($args[$i]) && $args[$i][0] !== '-') { 116 | $arg = $args[$i++]; 117 | } elseif (empty($opt[self::Optional])) { 118 | throw new \Exception("Option $name requires argument."); 119 | } 120 | } 121 | 122 | $this->checkArg($opt, $arg); 123 | 124 | if ( 125 | !empty($opt[self::Enum]) 126 | && !in_array(is_array($arg) ? reset($arg) : $arg, $opt[self::Enum], true) 127 | && !( 128 | $opt[self::Optional] 129 | && $arg === true 130 | ) 131 | ) { 132 | throw new \Exception("Value of option $name must be " . implode(', or ', $opt[self::Enum]) . '.'); 133 | } 134 | 135 | if (empty($opt[self::Repeatable])) { 136 | $params[$name] = $arg; 137 | } else { 138 | $params[$name][] = $arg; 139 | } 140 | } 141 | 142 | foreach ($this->options as $name => $opt) { 143 | if (isset($params[$name])) { 144 | continue; 145 | } elseif (isset($opt[self::Value])) { 146 | $params[$name] = $opt[self::Value]; 147 | } elseif ($name[0] !== '-' && empty($opt[self::Optional])) { 148 | throw new \Exception("Missing required argument <$name>."); 149 | } else { 150 | $params[$name] = null; 151 | } 152 | 153 | if (!empty($opt[self::Repeatable])) { 154 | $params[$name] = (array) $params[$name]; 155 | } 156 | } 157 | 158 | return $params; 159 | } 160 | 161 | 162 | public function help(): void 163 | { 164 | echo $this->help; 165 | } 166 | 167 | 168 | public function checkArg(array $opt, mixed &$arg): void 169 | { 170 | if (!empty($opt[self::Normalizer])) { 171 | $arg = call_user_func($opt[self::Normalizer], $arg); 172 | } 173 | 174 | if (!empty($opt[self::RealPath])) { 175 | $path = realpath($arg); 176 | if ($path === false) { 177 | throw new \Exception("File path '$arg' not found."); 178 | } 179 | 180 | $arg = $path; 181 | } 182 | } 183 | 184 | 185 | public function isEmpty(): bool 186 | { 187 | return !isset($_SERVER['argv']) || count($_SERVER['argv']) < 2; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Runner/Job.php: -------------------------------------------------------------------------------- 1 | getResult() !== Test::Prepared) { 52 | throw new \LogicException("Test '{$test->getSignature()}' already has result '{$test->getResult()}'."); 53 | } 54 | 55 | $test->stdout = ''; 56 | $test->stderr = ''; 57 | 58 | $this->test = $test; 59 | $this->interpreter = $interpreter; 60 | $this->envVars = (array) $envVars; 61 | } 62 | 63 | 64 | public function setTempDirectory(?string $path): void 65 | { 66 | $this->stderrFile = $path === null 67 | ? null 68 | : $path . DIRECTORY_SEPARATOR . 'Job.pid-' . getmypid() . '.' . uniqid() . '.stderr'; 69 | } 70 | 71 | 72 | public function setEnvironmentVariable(string $name, string $value): void 73 | { 74 | $this->envVars[$name] = $value; 75 | } 76 | 77 | 78 | public function getEnvironmentVariable(string $name): string 79 | { 80 | return $this->envVars[$name]; 81 | } 82 | 83 | 84 | /** 85 | * Runs single test. 86 | */ 87 | public function run(bool $async = false): void 88 | { 89 | foreach ($this->envVars as $name => $value) { 90 | putenv("$name=$value"); 91 | } 92 | 93 | $args = []; 94 | foreach ($this->test->getArguments() as $value) { 95 | $args[] = is_array($value) 96 | ? Helpers::escapeArg("--$value[0]=$value[1]") 97 | : Helpers::escapeArg($value); 98 | } 99 | 100 | $this->duration = -microtime(true); 101 | $this->proc = proc_open( 102 | $this->interpreter->getCommandLine() 103 | . ' -d register_argc_argv=on ' . Helpers::escapeArg($this->test->getFile()) . ' ' . implode(' ', $args), 104 | [ 105 | ['pipe', 'r'], 106 | ['pipe', 'w'], 107 | $this->stderrFile ? ['file', $this->stderrFile, 'w'] : ['pipe', 'w'], 108 | ], 109 | $pipes, 110 | dirname($this->test->getFile()), 111 | null, 112 | ['bypass_shell' => true], 113 | ); 114 | 115 | foreach (array_keys($this->envVars) as $name) { 116 | putenv($name); 117 | } 118 | 119 | [$stdin, $this->stdout] = $pipes; 120 | fclose($stdin); 121 | 122 | if (isset($pipes[2])) { 123 | fclose($pipes[2]); 124 | } 125 | 126 | if ($async) { 127 | stream_set_blocking($this->stdout, enable: false); // on Windows does not work with proc_open() 128 | } else { 129 | while ($this->isRunning()) { 130 | usleep(self::RunSleep); // stream_select() doesn't work with proc_open() 131 | } 132 | } 133 | } 134 | 135 | 136 | /** 137 | * Checks if the test is still running. 138 | */ 139 | public function isRunning(): bool 140 | { 141 | if (!is_resource($this->stdout)) { 142 | return false; 143 | } 144 | 145 | $this->test->stdout .= stream_get_contents($this->stdout); 146 | 147 | $status = proc_get_status($this->proc); 148 | if ($status['running']) { 149 | return true; 150 | } 151 | 152 | $this->duration += microtime(true); 153 | 154 | fclose($this->stdout); 155 | if ($this->stderrFile) { 156 | $this->test->stderr .= file_get_contents($this->stderrFile); 157 | unlink($this->stderrFile); 158 | } 159 | 160 | $code = proc_close($this->proc); 161 | $this->exitCode = $code === self::CodeNone 162 | ? $status['exitcode'] 163 | : $code; 164 | 165 | if ($this->interpreter->isCgi() && count($tmp = explode("\r\n\r\n", $this->test->stdout, 2)) >= 2) { 166 | [$headers, $this->test->stdout] = $tmp; 167 | foreach (explode("\r\n", $headers) as $header) { 168 | $pos = strpos($header, ':'); 169 | if ($pos !== false) { 170 | $this->headers[trim(substr($header, 0, $pos))] = trim(substr($header, $pos + 1)); 171 | } 172 | } 173 | } 174 | 175 | return false; 176 | } 177 | 178 | 179 | public function getTest(): Test 180 | { 181 | return $this->test; 182 | } 183 | 184 | 185 | /** 186 | * Returns exit code. 187 | */ 188 | public function getExitCode(): int 189 | { 190 | return $this->exitCode; 191 | } 192 | 193 | 194 | /** 195 | * Returns output headers. 196 | * @return string[] 197 | */ 198 | public function getHeaders(): array 199 | { 200 | return $this->headers; 201 | } 202 | 203 | 204 | /** 205 | * Returns process duration in seconds. 206 | */ 207 | public function getDuration(): ?float 208 | { 209 | return $this->duration > 0 210 | ? $this->duration 211 | : null; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/Runner/Output/ConsolePrinter.php: -------------------------------------------------------------------------------- 1 | runner = $runner; 44 | $this->displaySkipped = $displaySkipped; 45 | $this->file = fopen($file ?: 'php://output', 'w'); 46 | $this->symbols = match (true) { 47 | $ciderMode => [Test::Passed => '🍏', Test::Skipped => 's', Test::Failed => '🍎'], 48 | $lineMode => [Test::Passed => Dumper::color('lime', 'OK'), Test::Skipped => Dumper::color('yellow', 'SKIP'), Test::Failed => Dumper::color('white/red', 'FAIL')], 49 | default => [Test::Passed => '.', Test::Skipped => 's', Test::Failed => Dumper::color('white/red', 'F')], 50 | }; 51 | } 52 | 53 | 54 | public function begin(): void 55 | { 56 | $this->count = 0; 57 | $this->buffer = ''; 58 | $this->baseDir = null; 59 | $this->results = [ 60 | Test::Passed => 0, 61 | Test::Skipped => 0, 62 | Test::Failed => 0, 63 | ]; 64 | $this->time = -microtime(true); 65 | fwrite($this->file, $this->runner->getInterpreter()->getShortInfo() 66 | . ' | ' . $this->runner->getInterpreter()->getCommandLine() 67 | . " | {$this->runner->threadCount} thread" . ($this->runner->threadCount > 1 ? 's' : '') . "\n\n"); 68 | } 69 | 70 | 71 | public function prepare(Test $test): void 72 | { 73 | if ($this->baseDir === null) { 74 | $this->baseDir = dirname($test->getFile()) . DIRECTORY_SEPARATOR; 75 | } elseif (!str_starts_with($test->getFile(), $this->baseDir)) { 76 | $common = array_intersect_assoc( 77 | explode(DIRECTORY_SEPARATOR, $this->baseDir), 78 | explode(DIRECTORY_SEPARATOR, $test->getFile()), 79 | ); 80 | $this->baseDir = ''; 81 | $prev = 0; 82 | foreach ($common as $i => $part) { 83 | if ($i !== $prev++) { 84 | break; 85 | } 86 | 87 | $this->baseDir .= $part . DIRECTORY_SEPARATOR; 88 | } 89 | } 90 | 91 | $this->count++; 92 | } 93 | 94 | 95 | public function finish(Test $test): void 96 | { 97 | $this->results[$test->getResult()]++; 98 | fwrite( 99 | $this->file, 100 | $this->lineMode 101 | ? $this->generateFinishLine($test) 102 | : $this->symbols[$test->getResult()], 103 | ); 104 | 105 | $title = ($test->title ? "$test->title | " : '') . substr($test->getSignature(), strlen($this->baseDir)); 106 | $message = ' ' . str_replace("\n", "\n ", trim((string) $test->message)) . "\n\n"; 107 | $message = preg_replace('/^ $/m', '', $message); 108 | if ($test->getResult() === Test::Failed) { 109 | $this->buffer .= Dumper::color('red', "-- FAILED: $title") . "\n$message"; 110 | } elseif ($test->getResult() === Test::Skipped && $this->displaySkipped) { 111 | $this->buffer .= "-- Skipped: $title\n$message"; 112 | } 113 | } 114 | 115 | 116 | public function end(): void 117 | { 118 | $run = array_sum($this->results); 119 | fwrite($this->file, !$this->count ? "No tests found\n" : 120 | "\n\n" . $this->buffer . "\n" 121 | . ($this->results[Test::Failed] ? Dumper::color('white/red') . 'FAILURES!' : Dumper::color('white/green') . 'OK') 122 | . " ($this->count test" . ($this->count > 1 ? 's' : '') . ', ' 123 | . ($this->results[Test::Failed] ? $this->results[Test::Failed] . ' failure' . ($this->results[Test::Failed] > 1 ? 's' : '') . ', ' : '') 124 | . ($this->results[Test::Skipped] ? $this->results[Test::Skipped] . ' skipped, ' : '') 125 | . ($this->count !== $run ? ($this->count - $run) . ' not run, ' : '') 126 | . sprintf('%0.1f', $this->time + microtime(true)) . ' seconds)' . Dumper::color() . "\n"); 127 | 128 | $this->buffer = ''; 129 | } 130 | 131 | 132 | private function generateFinishLine(Test $test): string 133 | { 134 | $result = $test->getResult(); 135 | 136 | $shortFilePath = str_replace($this->baseDir, '', $test->getFile()); 137 | $shortDirPath = dirname($shortFilePath) . DIRECTORY_SEPARATOR; 138 | $basename = basename($shortFilePath); 139 | $fileText = $result === Test::Failed 140 | ? Dumper::color('red', $shortDirPath) . Dumper::color('white/red', $basename) 141 | : Dumper::color('gray', $shortDirPath) . Dumper::color('silver', $basename); 142 | 143 | $header = '· '; 144 | $titleText = $test->title 145 | ? Dumper::color('fuchsia', " [$test->title]") 146 | : ''; 147 | 148 | // failed tests messages will be printed after all tests are finished 149 | $message = ''; 150 | if ($result !== Test::Failed && $test->message) { 151 | $indent = str_repeat(' ', mb_strlen($header)); 152 | $message = preg_match('#\n#', $test->message) 153 | ? "\n$indent" . preg_replace('#\r?\n#', '\0' . $indent, $test->message) 154 | : Dumper::color('olive', "[$test->message]"); 155 | } 156 | 157 | return sprintf( 158 | "%s%s/%s %s%s %s %s %s\n", 159 | $header, 160 | array_sum($this->results), 161 | $this->count, 162 | $fileText, 163 | $titleText, 164 | $this->symbols[$result], 165 | Dumper::color('gray', sprintf('in %.2f s', $test->getDuration())), 166 | $message, 167 | ); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Runner/Output/JUnitPrinter.php: -------------------------------------------------------------------------------- 1 | file = fopen($file ?: 'php://output', 'w'); 31 | } 32 | 33 | 34 | public function begin(): void 35 | { 36 | $this->buffer = ''; 37 | $this->results = [ 38 | Test::Passed => 0, 39 | Test::Skipped => 0, 40 | Test::Failed => 0, 41 | ]; 42 | $this->startTime = microtime(true); 43 | fwrite($this->file, "\n\n"); 44 | } 45 | 46 | 47 | public function prepare(Test $test): void 48 | { 49 | } 50 | 51 | 52 | public function finish(Test $test): void 53 | { 54 | $this->results[$test->getResult()]++; 55 | $this->buffer .= "\t\tgetSignature()) . '" name="' . htmlspecialchars($test->getSignature()) . '"'; 56 | $this->buffer .= match ($test->getResult()) { 57 | Test::Failed => ">\n\t\t\tmessage, ENT_COMPAT | ENT_HTML5) . "\"/>\n\t\t\n", 58 | Test::Skipped => ">\n\t\t\t\n\t\t\n", 59 | Test::Passed => "/>\n", 60 | }; 61 | } 62 | 63 | 64 | public function end(): void 65 | { 66 | $time = sprintf('%0.1f', microtime(true) - $this->startTime); 67 | $output = $this->buffer; 68 | $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"; 69 | $this->buffer .= $output; 70 | $this->buffer .= "\t"; 71 | 72 | fwrite($this->file, $this->buffer . "\n\n"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /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/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/OutputHandler.php: -------------------------------------------------------------------------------- 1 | commandLine = Helpers::escapeArg($path); 29 | $proc = @proc_open( // @ is escalated to exception 30 | $this->commandLine . ' --version', 31 | [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], 32 | $pipes, 33 | null, 34 | null, 35 | ['bypass_shell' => true], 36 | ); 37 | if ($proc === false) { 38 | throw new \Exception("Cannot run PHP interpreter $path. Use -p option."); 39 | } 40 | 41 | fclose($pipes[0]); 42 | $output = stream_get_contents($pipes[1]); 43 | proc_close($proc); 44 | 45 | $args = ' ' . implode(' ', array_map([Helpers::class, 'escapeArg'], $args)); 46 | if (str_contains($output, 'phpdbg')) { 47 | $args = ' -qrrb -S cli' . $args; 48 | } 49 | 50 | $this->commandLine .= rtrim($args); 51 | 52 | $proc = proc_open( 53 | $this->commandLine . ' -d register_argc_argv=on ' . Helpers::escapeArg(__DIR__ . '/info.php') . ' serialized', 54 | [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], 55 | $pipes, 56 | null, 57 | null, 58 | ['bypass_shell' => true], 59 | ); 60 | $output = stream_get_contents($pipes[1]); 61 | $this->error = trim(stream_get_contents($pipes[2])); 62 | if (proc_close($proc)) { 63 | throw new \Exception("Unable to run $path: " . preg_replace('#[\r\n ]+#', ' ', $this->error)); 64 | } 65 | 66 | $parts = explode("\r\n\r\n", $output, 2); 67 | $this->cgi = count($parts) === 2; 68 | $this->info = @unserialize((string) strstr($parts[$this->cgi], 'O:8:"stdClass"')); 69 | $this->error .= strstr($parts[$this->cgi], 'O:8:"stdClass"', before_needle: true); 70 | if (!$this->info) { 71 | throw new \Exception("Unable to detect PHP version (output: $output)."); 72 | 73 | } elseif ($this->cgi && $this->error) { 74 | $this->error .= "\n(note that PHP CLI generates better error messages)"; 75 | } 76 | } 77 | 78 | 79 | /** 80 | * @return static 81 | */ 82 | public function withPhpIniOption(string $name, ?string $value = null): self 83 | { 84 | $me = clone $this; 85 | $me->commandLine .= ' -d ' . Helpers::escapeArg($name . ($value === null ? '' : "=$value")); 86 | return $me; 87 | } 88 | 89 | 90 | public function getCommandLine(): string 91 | { 92 | return $this->commandLine; 93 | } 94 | 95 | 96 | public function getVersion(): string 97 | { 98 | return $this->info->version; 99 | } 100 | 101 | 102 | public function getCodeCoverageEngines(): array 103 | { 104 | return $this->info->codeCoverageEngines; 105 | } 106 | 107 | 108 | public function isCgi(): bool 109 | { 110 | return $this->cgi; 111 | } 112 | 113 | 114 | public function getStartupError(): string 115 | { 116 | return $this->error; 117 | } 118 | 119 | 120 | public function getShortInfo(): string 121 | { 122 | return "PHP {$this->info->version} ({$this->info->sapi})" 123 | . ($this->info->phpDbgVersion ? "; PHPDBG {$this->info->phpDbgVersion}" : ''); 124 | } 125 | 126 | 127 | public function hasExtension(string $name): bool 128 | { 129 | return in_array(strtolower($name), array_map('strtolower', $this->info->extensions), true); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Runner/Runner.php: -------------------------------------------------------------------------------- 1 | interpreter = $interpreter; 45 | $this->testHandler = new TestHandler($this); 46 | } 47 | 48 | 49 | public function setEnvironmentVariable(string $name, string $value): void 50 | { 51 | $this->envVars[$name] = $value; 52 | } 53 | 54 | 55 | public function getEnvironmentVariables(): array 56 | { 57 | return $this->envVars; 58 | } 59 | 60 | 61 | public function addPhpIniOption(string $name, ?string $value = null): void 62 | { 63 | $this->interpreter = $this->interpreter->withPhpIniOption($name, $value); 64 | } 65 | 66 | 67 | public function setTempDirectory(?string $path): void 68 | { 69 | $this->tempDir = $path; 70 | $this->testHandler->setTempDirectory($path); 71 | } 72 | 73 | 74 | /** 75 | * Runs all tests. 76 | */ 77 | public function run(): bool 78 | { 79 | $this->result = true; 80 | $this->interrupted = false; 81 | 82 | foreach ($this->outputHandlers as $handler) { 83 | $handler->begin(); 84 | } 85 | 86 | $this->jobs = $running = []; 87 | foreach ($this->paths as $path) { 88 | $this->findTests($path); 89 | } 90 | 91 | if ($this->tempDir) { 92 | usort( 93 | $this->jobs, 94 | fn(Job $a, Job $b): int => $this->getLastResult($a->getTest()) - $this->getLastResult($b->getTest()), 95 | ); 96 | } 97 | 98 | $threads = range(1, $this->threadCount); 99 | 100 | $async = $this->threadCount > 1 && count($this->jobs) > 1; 101 | 102 | try { 103 | while (($this->jobs || $running) && !$this->interrupted) { 104 | while ($threads && $this->jobs) { 105 | $running[] = $job = array_shift($this->jobs); 106 | $job->setEnvironmentVariable(Environment::VariableThread, (string) array_shift($threads)); 107 | $job->run(async: $async); 108 | } 109 | 110 | if ($async) { 111 | usleep(Job::RunSleep); // stream_select() doesn't work with proc_open() 112 | } 113 | 114 | foreach ($running as $key => $job) { 115 | if ($this->interrupted) { 116 | break 2; 117 | } 118 | 119 | if (!$job->isRunning()) { 120 | $threads[] = $job->getEnvironmentVariable(Environment::VariableThread); 121 | $this->testHandler->assess($job); 122 | unset($running[$key]); 123 | } 124 | } 125 | } 126 | } finally { 127 | foreach ($this->outputHandlers as $handler) { 128 | $handler->end(); 129 | } 130 | } 131 | 132 | return $this->result; 133 | } 134 | 135 | 136 | private function findTests(string $path): void 137 | { 138 | if (strpbrk($path, '*?') === false && !file_exists($path)) { 139 | throw new \InvalidArgumentException("File or directory '$path' not found."); 140 | } 141 | 142 | if (is_dir($path)) { 143 | foreach (glob(str_replace('[', '[[]', $path) . '/*', GLOB_ONLYDIR) ?: [] as $dir) { 144 | if (in_array(basename($dir), $this->ignoreDirs, true)) { 145 | continue; 146 | } 147 | 148 | $this->findTests($dir); 149 | } 150 | 151 | $this->findTests($path . '/*.phpt'); 152 | $this->findTests($path . '/*Test.php'); 153 | 154 | } else { 155 | foreach (glob(str_replace('[', '[[]', $path)) ?: [] as $file) { 156 | if (is_file($file)) { 157 | $this->testHandler->initiate(realpath($file)); 158 | } 159 | } 160 | } 161 | } 162 | 163 | 164 | /** 165 | * Appends new job to queue. 166 | */ 167 | public function addJob(Job $job): void 168 | { 169 | $this->jobs[] = $job; 170 | } 171 | 172 | 173 | public function prepareTest(Test $test): void 174 | { 175 | foreach ($this->outputHandlers as $handler) { 176 | $handler->prepare($test); 177 | } 178 | } 179 | 180 | 181 | /** 182 | * Writes to output handlers. 183 | */ 184 | public function finishTest(Test $test): void 185 | { 186 | $this->result = $this->result && ($test->getResult() !== Test::Failed); 187 | 188 | foreach ($this->outputHandlers as $handler) { 189 | $handler->finish($test); 190 | } 191 | 192 | if ($this->tempDir) { 193 | $lastResult = &$this->lastResults[$test->getSignature()]; 194 | if ($lastResult !== $test->getResult()) { 195 | file_put_contents($this->getLastResultFilename($test), $lastResult = $test->getResult()); 196 | } 197 | } 198 | 199 | if ($this->stopOnFail && $test->getResult() === Test::Failed) { 200 | $this->interrupted = true; 201 | } 202 | } 203 | 204 | 205 | public function getInterpreter(): PhpInterpreter 206 | { 207 | return $this->interpreter; 208 | } 209 | 210 | 211 | private function getLastResult(Test $test): int 212 | { 213 | $signature = $test->getSignature(); 214 | if (isset($this->lastResults[$signature])) { 215 | return $this->lastResults[$signature]; 216 | } 217 | 218 | $file = $this->getLastResultFilename($test); 219 | if (is_file($file)) { 220 | return $this->lastResults[$signature] = (int) file_get_contents($file); 221 | } 222 | 223 | return $this->lastResults[$signature] = Test::Prepared; 224 | } 225 | 226 | 227 | private function getLastResultFilename(Test $test): string 228 | { 229 | return $this->tempDir 230 | . DIRECTORY_SEPARATOR 231 | . pathinfo($test->getFile(), PATHINFO_FILENAME) 232 | . '.' 233 | . substr(md5($test->getSignature()), 0, 5) 234 | . '.result'; 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/Runner/Test.php: -------------------------------------------------------------------------------- 1 | file = $file; 46 | $this->title = $title; 47 | } 48 | 49 | 50 | public function getFile(): string 51 | { 52 | return $this->file; 53 | } 54 | 55 | 56 | /** 57 | * @return string[]|string[][] 58 | */ 59 | public function getArguments(): array 60 | { 61 | return $this->args; 62 | } 63 | 64 | 65 | public function getSignature(): string 66 | { 67 | $args = implode(' ', array_map(fn($arg): string => is_array($arg) ? "$arg[0]=$arg[1]" : $arg, $this->args)); 68 | 69 | return $this->file . ($args ? " $args" : ''); 70 | } 71 | 72 | 73 | public function getResult(): int 74 | { 75 | return $this->result; 76 | } 77 | 78 | 79 | public function hasResult(): bool 80 | { 81 | return $this->result !== self::Prepared; 82 | } 83 | 84 | 85 | /** 86 | * Duration in seconds. 87 | */ 88 | public function getDuration(): ?float 89 | { 90 | return $this->duration; 91 | } 92 | 93 | 94 | /** 95 | * Full output (stdout + stderr) 96 | */ 97 | public function getOutput(): string 98 | { 99 | return $this->stdout . ($this->stderr ? "\nSTDERR:\n" . $this->stderr : ''); 100 | } 101 | 102 | 103 | public function withTitle(string $title): self 104 | { 105 | if ($this->hasResult()) { 106 | throw new \LogicException('Cannot change title to test which already has a result.'); 107 | } 108 | 109 | $me = clone $this; 110 | $me->title = $title; 111 | return $me; 112 | } 113 | 114 | 115 | /** 116 | * @return static 117 | */ 118 | public function withArguments(array $args): self 119 | { 120 | if ($this->hasResult()) { 121 | throw new \LogicException('Cannot change arguments of test which already has a result.'); 122 | } 123 | 124 | $me = clone $this; 125 | foreach ($args as $name => $values) { 126 | foreach ((array) $values as $value) { 127 | $me->args[] = is_int($name) 128 | ? "$value" 129 | : [$name, "$value"]; 130 | } 131 | } 132 | 133 | return $me; 134 | } 135 | 136 | 137 | /** 138 | * @return static 139 | */ 140 | public function withResult(int $result, ?string $message, ?float $duration = null): self 141 | { 142 | if ($this->hasResult()) { 143 | throw new \LogicException("Result of test is already set to $this->result with message '$this->message'."); 144 | } 145 | 146 | $me = clone $this; 147 | $me->result = $result; 148 | $me->message = $message; 149 | $me->duration = $duration; 150 | return $me; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Runner/TestHandler.php: -------------------------------------------------------------------------------- 1 | runner = $runner; 31 | } 32 | 33 | 34 | public function setTempDirectory(?string $path): void 35 | { 36 | $this->tempDir = $path; 37 | } 38 | 39 | 40 | public function initiate(string $file): void 41 | { 42 | [$annotations, $title] = $this->getAnnotations($file); 43 | $php = $this->runner->getInterpreter(); 44 | 45 | $tests = [new Test($file, $title)]; 46 | foreach (get_class_methods($this) as $method) { 47 | if (!preg_match('#^initiate(.+)#', strtolower($method), $m) || !isset($annotations[$m[1]])) { 48 | continue; 49 | } 50 | 51 | foreach ((array) $annotations[$m[1]] as $value) { 52 | /** @var Test[] $prepared */ 53 | $prepared = []; 54 | foreach ($tests as $test) { 55 | $res = $this->$method($test, $value, $php); 56 | if ($res === null) { 57 | $prepared[] = $test; 58 | } else { 59 | foreach (is_array($res) ? $res : [$res] as $testVariety) { 60 | \assert($testVariety instanceof Test); 61 | if ($testVariety->hasResult()) { 62 | $this->runner->prepareTest($testVariety); 63 | $this->runner->finishTest($testVariety); 64 | } else { 65 | $prepared[] = $testVariety; 66 | } 67 | } 68 | } 69 | } 70 | 71 | $tests = $prepared; 72 | } 73 | } 74 | 75 | foreach ($tests as $test) { 76 | $this->runner->prepareTest($test); 77 | $job = new Job($test, $php, $this->runner->getEnvironmentVariables()); 78 | $job->setTempDirectory($this->tempDir); 79 | $this->runner->addJob($job); 80 | } 81 | } 82 | 83 | 84 | public function assess(Job $job): void 85 | { 86 | $test = $job->getTest(); 87 | $annotations = $this->getAnnotations($test->getFile())[0] += [ 88 | 'exitcode' => Job::CodeOk, 89 | 'httpcode' => self::HttpOk, 90 | ]; 91 | 92 | foreach (get_class_methods($this) as $method) { 93 | if (!preg_match('#^assess(.+)#', strtolower($method), $m) || !isset($annotations[$m[1]])) { 94 | continue; 95 | } 96 | 97 | foreach ((array) $annotations[$m[1]] as $arg) { 98 | /** @var Test|null $res */ 99 | if ($res = $this->$method($job, $arg)) { 100 | $this->runner->finishTest($res); 101 | return; 102 | } 103 | } 104 | } 105 | 106 | $this->runner->finishTest($test->withResult(Test::Passed, $test->message, $job->getDuration())); 107 | } 108 | 109 | 110 | private function initiateSkip(Test $test, string $message): Test 111 | { 112 | return $test->withResult(Test::Skipped, $message); 113 | } 114 | 115 | 116 | private function initiatePhpVersion(Test $test, string $version, PhpInterpreter $interpreter): ?Test 117 | { 118 | if (preg_match('#^(<=|<|==|=|!=|<>|>=|>)?\s*(.+)#', $version, $matches) 119 | && version_compare($matches[2], $interpreter->getVersion(), $matches[1] ?: '>=')) { 120 | return $test->withResult(Test::Skipped, "Requires PHP $version."); 121 | } 122 | 123 | return null; 124 | } 125 | 126 | 127 | private function initiatePhpExtension(Test $test, string $value, PhpInterpreter $interpreter): ?Test 128 | { 129 | foreach (preg_split('#[\s,]+#', $value) as $extension) { 130 | if (!$interpreter->hasExtension($extension)) { 131 | return $test->withResult(Test::Skipped, "Requires PHP extension $extension."); 132 | } 133 | } 134 | 135 | return null; 136 | } 137 | 138 | 139 | private function initiatePhpIni(Test $test, string $pair, PhpInterpreter &$interpreter): void 140 | { 141 | [$name, $value] = explode('=', $pair, 2) + [1 => null]; 142 | $interpreter = $interpreter->withPhpIniOption($name, $value); 143 | } 144 | 145 | 146 | private function initiateDataProvider(Test $test, string $provider): array|Test 147 | { 148 | try { 149 | [$dataFile, $query, $optional] = Tester\DataProvider::parseAnnotation($provider, $test->getFile()); 150 | $data = Tester\DataProvider::load($dataFile, $query); 151 | if (count($data) < 1) { 152 | throw new \Exception("No records in data provider file '{$test->getFile()}'" . ($query ? " for query '$query'" : '') . '.'); 153 | } 154 | } catch (\Throwable $e) { 155 | return $test->withResult(empty($optional) ? Test::Failed : Test::Skipped, $e->getMessage()); 156 | } 157 | 158 | return array_map( 159 | fn(string $item): Test => $test->withArguments(['dataprovider' => "$item|$dataFile"]), 160 | array_keys($data), 161 | ); 162 | } 163 | 164 | 165 | private function initiateMultiple(Test $test, string $count): array 166 | { 167 | return array_map( 168 | fn(int $i): Test => $test->withArguments(['multiple' => $i]), 169 | range(0, (int) $count - 1), 170 | ); 171 | } 172 | 173 | 174 | private function initiateTestCase(Test $test, $foo, PhpInterpreter $interpreter) 175 | { 176 | $methods = null; 177 | 178 | if ($this->tempDir) { 179 | $cacheFile = $this->tempDir . DIRECTORY_SEPARATOR . 'TestHandler.testCase.' . md5($test->getSignature()) . '.list'; 180 | if (is_file($cacheFile)) { 181 | $cache = unserialize(file_get_contents($cacheFile)); 182 | 183 | $valid = true; 184 | foreach ($cache['files'] as $path => $mTime) { 185 | if (!is_file($path) || filemtime($path) !== $mTime) { 186 | $valid = false; 187 | break; 188 | } 189 | } 190 | 191 | if ($valid) { 192 | $methods = $cache['methods']; 193 | } 194 | } 195 | } 196 | 197 | if ($methods === null) { 198 | $job = new Job($test->withArguments(['method' => TestCase::ListMethods]), $interpreter, $this->runner->getEnvironmentVariables()); 199 | $job->setTempDirectory($this->tempDir); 200 | $job->run(); 201 | 202 | if (in_array($job->getExitCode(), [Job::CodeError, Job::CodeFail, Job::CodeSkip], true)) { 203 | return $test->withResult($job->getExitCode() === Job::CodeSkip ? Test::Skipped : Test::Failed, $job->getTest()->getOutput()); 204 | } 205 | 206 | $stdout = $job->getTest()->stdout; 207 | 208 | if (!preg_match('#^TestCase:([^\n]+)$#m', $stdout, $m)) { 209 | return $test->withResult(Test::Failed, "Cannot list TestCase methods in file '{$test->getFile()}'. Do you call TestCase::run() in it?"); 210 | } 211 | 212 | $testCaseClass = $m[1]; 213 | 214 | preg_match_all('#^Method:([^\n]+)$#m', $stdout, $m); 215 | if (count($m[1]) < 1) { 216 | return $test->withResult(Test::Skipped, "Class $testCaseClass in file '{$test->getFile()}' does not contain test methods."); 217 | } 218 | 219 | $methods = $m[1]; 220 | 221 | if ($this->tempDir) { 222 | preg_match_all('#^Dependency:([^\n]+)$#m', $stdout, $m); 223 | file_put_contents($cacheFile, serialize([ 224 | 'methods' => $methods, 225 | 'files' => array_combine($m[1], array_map('filemtime', $m[1])), 226 | ])); 227 | } 228 | } 229 | 230 | return array_map( 231 | fn(string $method): Test => $test 232 | ->withTitle(trim("$test->title $method")) 233 | ->withArguments(['method' => $method]), 234 | $methods, 235 | ); 236 | } 237 | 238 | 239 | private function assessExitCode(Job $job, string|int $code): ?Test 240 | { 241 | $code = (int) $code; 242 | if ($job->getExitCode() === Job::CodeSkip) { 243 | $message = preg_match('#.*Skipped:\n(.*?)$#Ds', $output = $job->getTest()->stdout, $m) 244 | ? $m[1] 245 | : $output; 246 | return $job->getTest()->withResult(Test::Skipped, trim($message)); 247 | 248 | } elseif ($job->getExitCode() !== $code) { 249 | $message = $job->getExitCode() !== Job::CodeFail 250 | ? "Exited with error code {$job->getExitCode()} (expected $code)" 251 | : ''; 252 | return $job->getTest()->withResult(Test::Failed, trim($message . "\n" . $job->getTest()->getOutput())); 253 | } 254 | 255 | return null; 256 | } 257 | 258 | 259 | private function assessHttpCode(Job $job, string|int $code): ?Test 260 | { 261 | if (!$this->runner->getInterpreter()->isCgi()) { 262 | return null; 263 | } 264 | 265 | $headers = $job->getHeaders(); 266 | $actual = (int) ($headers['Status'] ?? self::HttpOk); 267 | $code = (int) $code; 268 | return $code && $code !== $actual 269 | ? $job->getTest()->withResult(Test::Failed, "Exited with HTTP code $actual (expected $code)") 270 | : null; 271 | } 272 | 273 | 274 | private function assessOutputMatchFile(Job $job, string $file): ?Test 275 | { 276 | $file = dirname($job->getTest()->getFile()) . DIRECTORY_SEPARATOR . $file; 277 | if (!is_file($file)) { 278 | return $job->getTest()->withResult(Test::Failed, "Missing matching file '$file'."); 279 | } 280 | 281 | return $this->assessOutputMatch($job, file_get_contents($file)); 282 | } 283 | 284 | 285 | private function assessOutputMatch(Job $job, string $content): ?Test 286 | { 287 | $actual = $job->getTest()->stdout; 288 | if (!Tester\Assert::isMatching($content, $actual)) { 289 | [$content, $actual] = Tester\Assert::expandMatchingPatterns($content, $actual); 290 | Dumper::saveOutput($job->getTest()->getFile(), $actual, '.actual'); 291 | Dumper::saveOutput($job->getTest()->getFile(), $content, '.expected'); 292 | return $job->getTest()->withResult(Test::Failed, 'Failed: output should match ' . Dumper::toLine($content)); 293 | } 294 | 295 | return null; 296 | } 297 | 298 | 299 | private function getAnnotations(string $file): array 300 | { 301 | $annotations = Helpers::parseDocComment(file_get_contents($file)); 302 | $testTitle = isset($annotations[0]) 303 | ? preg_replace('#^TEST:\s*#i', '', $annotations[0]) 304 | : null; 305 | return [$annotations, $testTitle]; 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/Runner/exceptions.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) => sprintf('%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/bootstrap.php: -------------------------------------------------------------------------------- 1 | run()); 38 | --------------------------------------------------------------------------------