no message
This commit is contained in:
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
build: off
|
||||
cache:
|
||||
- c:\php -> appveyor.yml
|
||||
|
||||
clone_folder: c:\projects\tester
|
||||
|
||||
init:
|
||||
- SET ANSICON=121x90 (121x90)
|
||||
|
||||
install:
|
||||
# Install PHP 7.1
|
||||
- IF EXIST c:\php\71 (SET PHP=0) ELSE (SET PHP=1)
|
||||
- IF %PHP%==1 mkdir c:\php\71
|
||||
- IF %PHP%==1 cd c:\php\71
|
||||
- IF %PHP%==1 curl https://windows.php.net/downloads/releases/archives/php-7.1.6-Win32-VC14-x64.zip --output php.zip
|
||||
- IF %PHP%==1 7z x php.zip >nul
|
||||
- IF %PHP%==1 del /Q *.zip
|
||||
|
||||
# Install PHP 7.2
|
||||
- IF EXIST c:\php\72 (SET PHP=0) ELSE (SET PHP=1)
|
||||
- IF %PHP%==1 mkdir c:\php\72
|
||||
- IF %PHP%==1 cd c:\php\72
|
||||
- IF %PHP%==1 curl https://windows.php.net/downloads/releases/archives/php-7.2.2-Win32-VC15-x64.zip --output php.zip
|
||||
- IF %PHP%==1 7z x php.zip >nul
|
||||
- IF %PHP%==1 del /Q *.zip
|
||||
|
||||
- cd c:\projects\tester
|
||||
|
||||
test_script:
|
||||
- c:\php\71\php src\tester tests -s -p c:\php\71\php
|
||||
- c:\php\71\php src\tester tests -s -p c:\php\71\php-cgi
|
||||
- c:\php\71\php src\tester tests -s -p c:\php\71\phpdbg
|
||||
|
||||
- c:\php\72\php src\tester tests -s -p c:\php\72\php
|
||||
- c:\php\72\php src\tester tests -s -p c:\php\72\php-cgi
|
||||
- c:\php\72\php src\tester tests -s -p c:\php\72\phpdbg
|
||||
|
||||
on_failure:
|
||||
# Print *.actual content
|
||||
- for /r %%x in (*.actual) do ( type "%%x" )
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "nette/tester",
|
||||
"description": "Nette Tester: enjoyable unit testing in PHP with code coverage reporter. 🍏🍏🍎🍏",
|
||||
"homepage": "https://tester.nette.org",
|
||||
"keywords": ["testing", "unit", "nette", "phpunit", "code coverage", "xdebug", "phpdbg", "pcov", "clover", "assertions"],
|
||||
"license": ["BSD-3-Clause", "GPL-2.0", "GPL-3.0"],
|
||||
"authors": [
|
||||
{
|
||||
"name": "David Grudl",
|
||||
"homepage": "https://davidgrudl.com"
|
||||
},
|
||||
{
|
||||
"name": "Nette Community",
|
||||
"homepage": "https://nette.org/contributors"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=7.1"
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": ["src/"]
|
||||
},
|
||||
"bin": ["src/tester"],
|
||||
"extra": {
|
||||
"branch-alias": { "dev-master": "2.3-dev" }
|
||||
}
|
||||
}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
How to contribute & use the issue tracker
|
||||
=========================================
|
||||
|
||||
Nette welcomes your contributions. There are several ways to help out:
|
||||
|
||||
* Create an issue on GitHub, if you have found a bug
|
||||
* Write test cases for open bug issues
|
||||
* Write fixes for open bug/feature issues, preferably with test cases included
|
||||
* Contribute to the [documentation](https://nette.org/en/writing)
|
||||
|
||||
Issues
|
||||
------
|
||||
|
||||
Please **do not use the issue tracker to ask questions**. We will be happy to help you
|
||||
on [Nette forum](https://forum.nette.org) or chat with us on [Gitter](https://gitter.im/nette/nette).
|
||||
|
||||
A good bug report shouldn't leave others needing to chase you up for more
|
||||
information. Please try to be as detailed as possible in your report.
|
||||
|
||||
**Feature requests** are welcome. But take a moment to find out whether your idea
|
||||
fits with the scope and aims of the project. It's up to *you* to make a strong
|
||||
case to convince the project's developers of the merits of this feature.
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
If you'd like to contribute, please take a moment to read [the contributing guide](https://nette.org/en/contributing).
|
||||
|
||||
The best way to propose a feature is to discuss your ideas on [Nette forum](https://forum.nette.org) before implementing them.
|
||||
|
||||
Please do not fix whitespace, format code, or make a purely cosmetic patch.
|
||||
|
||||
Thanks! :heart:
|
||||
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
Licenses
|
||||
========
|
||||
|
||||
Good news! You may use Nette Tester under the terms of either
|
||||
the New BSD License or the GNU General Public License (GPL) version 2 or 3.
|
||||
|
||||
|
||||
|
||||
New BSD License
|
||||
---------------
|
||||
|
||||
Copyright (c) 2004, 2013 David Grudl (https://davidgrudl.com)
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of "Nette Tester" nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
This software is provided by the copyright holders and contributors "as is" and
|
||||
any express or implied warranties, including, but not limited to, the implied
|
||||
warranties of merchantability and fitness for a particular purpose are
|
||||
disclaimed. In no event shall the copyright owner or contributors be liable for
|
||||
any direct, indirect, incidental, special, exemplary, or consequential damages
|
||||
(including, but not limited to, procurement of substitute goods or services;
|
||||
loss of use, data, or profits; or business interruption) however caused and on
|
||||
any theory of liability, whether in contract, strict liability, or tort
|
||||
(including negligence or otherwise) arising in any way out of the use of this
|
||||
software, even if advised of the possibility of such damage.
|
||||
|
||||
|
||||
|
||||
GNU General Public License
|
||||
--------------------------
|
||||
|
||||
GPL licenses are very very long, so instead of including them here we offer
|
||||
you URLs with full text:
|
||||
|
||||
- [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html)
|
||||
- [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html)
|
||||
Vendored
+213
@@ -0,0 +1,213 @@
|
||||
[Nette Tester](https://tester.nette.org): enjoyable unit testing
|
||||
================================================================
|
||||
|
||||
[](https://packagist.org/packages/nette/tester)
|
||||
[](https://travis-ci.org/nette/tester)
|
||||
[](https://ci.appveyor.com/project/dg/tester/branch/master)
|
||||
[](https://github.com/nette/tester/releases)
|
||||
[](https://github.com/nette/tester/blob/master/license.md)
|
||||
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
Nette Tester is a productive and enjoyable unit testing framework. It's used by
|
||||
the [Nette Framework](https://nette.org) and is capable of testing any PHP code.
|
||||
|
||||
Documentation is available on the [Nette Tester website](https://tester.nette.org).
|
||||
Read the [blog](https://blog.nette.org/category/tester/) for new information.
|
||||
|
||||
If you like Nette, **[please make a donation now](https://nette.org/donate)**. Thank you!
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
The recommended way to install Nette Tester is through Composer:
|
||||
|
||||
```
|
||||
composer require nette/tester --dev
|
||||
```
|
||||
|
||||
Alternatively, you can download the [tester.phar](https://github.com/nette/tester/releases) file.
|
||||
|
||||
Nette Tester 2.0 requires PHP 5.6 and supports PHP up to 7.3. The 2.1 version and newer require PHP 7.1.
|
||||
Collecting and processing code coverage information depends on Xdebug or PCOV extension, or PHPDBG SAPI.
|
||||
|
||||
|
||||
Writing Tests
|
||||
-------------
|
||||
|
||||
Imagine that we are testing this simple class:
|
||||
|
||||
```php
|
||||
class Greeting
|
||||
{
|
||||
function say($name)
|
||||
{
|
||||
if (!$name) {
|
||||
throw new InvalidArgumentException('Invalid name.');
|
||||
}
|
||||
return "Hello $name";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
So we create test file named `greeting.test.phpt`:
|
||||
|
||||
```php
|
||||
require 'src/bootstrap.php';
|
||||
|
||||
use Tester\Assert;
|
||||
|
||||
$h = new Greeting;
|
||||
|
||||
// use an assertion function to test say()
|
||||
Assert::same('Hello John', $h->say('John'));
|
||||
```
|
||||
|
||||
Thats' all!
|
||||
|
||||
Now we run tests from command-line using the `tester` command:
|
||||
|
||||
```
|
||||
> tester
|
||||
_____ ___ ___ _____ ___ ___
|
||||
|_ _/ __)( __/_ _/ __)| _ )
|
||||
|_| \___ /___) |_| \___ |_|_\ v2.2.0
|
||||
|
||||
PHP 7.3.3 | php -n | 8 threads
|
||||
.
|
||||
OK (1 tests, 0 skipped, 0.0 seconds)
|
||||
```
|
||||
|
||||
Nette Tester prints dot for successful test, F for failed test
|
||||
and S when the test has been skipped.
|
||||
|
||||
|
||||
Assertions
|
||||
----------
|
||||
|
||||
This table shows all assertions (class `Assert` means `Tester\Assert`):
|
||||
|
||||
- `Assert::same($expected, $actual)` - Reports an error if $expected and $actual are not the same.
|
||||
- `Assert::notSame($expected, $actual)` - Reports an error if $expected and $actual are the same.
|
||||
- `Assert::equal($expected, $actual)` - Like same(), but identity of objects and the order of keys in the arrays are ignored.
|
||||
- `Assert::notEqual($expected, $actual)` - Like notSame(), but identity of objects and arrays order are ignored.
|
||||
- `Assert::contains($needle, array $haystack)` - Reports an error if $needle is not an element of $haystack.
|
||||
- `Assert::contains($needle, string $haystack)` - Reports an error if $needle is not a substring of $haystack.
|
||||
- `Assert::notContains($needle, array $haystack)` - Reports an error if $needle is an element of $haystack.
|
||||
- `Assert::notContains($needle, string $haystack)` - Reports an error if $needle is a substring of $haystack.
|
||||
- `Assert::true($value)` - Reports an error if $value is not true.
|
||||
- `Assert::false($value)` - Reports an error if $value is not false.
|
||||
- `Assert::truthy($value)` - Reports an error if $value is not truthy.
|
||||
- `Assert::falsey($value)` - Reports an error if $value is not falsey.
|
||||
- `Assert::null($value)` - Reports an error if $value is not null.
|
||||
- `Assert::nan($value)` - Reports an error if $value is not NAN.
|
||||
- `Assert::type($type, $value)` - Reports an error if the variable $value is not of PHP or class type $type.
|
||||
- `Assert::exception($closure, $class, $message = null, $code = null)` - Checks if the function throws exception.
|
||||
- `Assert::error($closure, $level, $message = null)` - Checks if the function $closure throws PHP warning/notice/error.
|
||||
- `Assert::noError($closure)` - Checks that the function $closure does not throw PHP warning/notice/error or exception.
|
||||
- `Assert::match($pattern, $value)` - Compares result using regular expression or mask.
|
||||
- `Assert::matchFile($file, $value)` - Compares result using regular expression or mask sorted in file.
|
||||
- `Assert::count($count, $value)` - Reports an error if number of items in $value is not $count.
|
||||
|
||||
Testing exceptions:
|
||||
|
||||
```php
|
||||
Assert::exception(function () {
|
||||
$h = new Greeting;
|
||||
$h->say(null);
|
||||
}, InvalidArgumentException::class, 'Invalid name.');
|
||||
```
|
||||
|
||||
Testing PHP errors, warnings or notices:
|
||||
|
||||
|
||||
```php
|
||||
Assert::error(function () {
|
||||
$h = new Greeting;
|
||||
echo $h->abc;
|
||||
}, E_NOTICE, 'Undefined property: Greeting::$abc');
|
||||
```
|
||||
|
||||
Tips and features
|
||||
-----------------
|
||||
|
||||
Running unit tests manually is annoying, so let Nette Tester to watch your folder
|
||||
with code and automatically re-run tests whenever code is changed:
|
||||
|
||||
```
|
||||
tester -w /my/source/codes
|
||||
```
|
||||
|
||||
Running tests in parallel is very much faster and Nette Tester uses 8 threads as default.
|
||||
If you wish to run the tests in series use:
|
||||
|
||||
```
|
||||
tester -j 1
|
||||
```
|
||||
|
||||
How do you find code that is not yet tested? Use Code-Coverage Analysis. This feature
|
||||
requires you have installed [Xdebug](https://xdebug.org/) or [PCOV](https://github.com/krakjoe/pcov)
|
||||
extension, or you are using PHPDBG SAPI. This will generate nice HTML report in `coverage.html`.
|
||||
|
||||
```
|
||||
tester . -c php.ini --coverage coverage.html --coverage-src /my/source/codes
|
||||
```
|
||||
|
||||
We can load Nette Tester using Composer's autoloader. In this case
|
||||
it is important to setup Nette Tester environment:
|
||||
|
||||
```php
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
Tester\Environment::setup();
|
||||
```
|
||||
|
||||
We can also test HTML pages. Let the [template engine](https://latte.nette.org) generate
|
||||
HTML code or download existing page to `$html` variable. We will check whether
|
||||
the page contains form fields for username and password. The syntax is the
|
||||
same as the CSS selectors:
|
||||
|
||||
```php
|
||||
$dom = Tester\DomQuery::fromHtml($html);
|
||||
|
||||
Assert::true($dom->has('input[name="username"]'));
|
||||
Assert::true($dom->has('input[name="password"]'));
|
||||
```
|
||||
|
||||
For more inspiration see how [Nette Tester tests itself](https://github.com/nette/tester/tree/master/tests).
|
||||
|
||||
|
||||
Running tests
|
||||
-------------
|
||||
|
||||
The command-line test runner can be invoked through the `tester` command (or `php tester.php`). Take a look
|
||||
at the command-line options:
|
||||
|
||||
```
|
||||
> tester
|
||||
|
||||
Usage:
|
||||
tester.php [options] [<test file> | <directory>]...
|
||||
|
||||
Options:
|
||||
-p <path> Specify PHP interpreter to run (default: php).
|
||||
-c <path> Look for php.ini file (or look in directory) <path>.
|
||||
-C Use system-wide php.ini.
|
||||
-l | --log <path> Write log to file <path>.
|
||||
-d <key=value>... Define INI entry 'key' with value 'val'.
|
||||
-s Show information about skipped tests.
|
||||
--stop-on-fail Stop execution upon the first failure.
|
||||
-j <num> Run <num> jobs in parallel (default: 8).
|
||||
-o <console|tap|junit|none> Specify output format.
|
||||
-w | --watch <path> Watch directory.
|
||||
-i | --info Show tests environment info and exit.
|
||||
--setup <path> Script for runner setup.
|
||||
--temp <path> Path to temporary directory. Default by sys_get_temp_dir().
|
||||
--colors [1|0] Enable or disable colors.
|
||||
--coverage <path> Generate code coverage report to file.
|
||||
--coverage-src <path> Path to source code.
|
||||
-h | --help This help.
|
||||
```
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\CodeCoverage;
|
||||
|
||||
|
||||
/**
|
||||
* Code coverage collector.
|
||||
*/
|
||||
class Collector
|
||||
{
|
||||
public const
|
||||
ENGINE_PCOV = 'PCOV',
|
||||
ENGINE_PHPDBG = 'PHPDBG',
|
||||
ENGINE_XDEBUG = 'Xdebug';
|
||||
|
||||
/** @var resource */
|
||||
private static $file;
|
||||
|
||||
/** @var string */
|
||||
private static $engine;
|
||||
|
||||
|
||||
public static function detectEngines(): array
|
||||
{
|
||||
return array_filter([
|
||||
extension_loaded('pcov') ? self::ENGINE_PCOV : null,
|
||||
defined('PHPDBG_VERSION') ? self::ENGINE_PHPDBG : null,
|
||||
extension_loaded('xdebug') ? self::ENGINE_XDEBUG : null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public static function isStarted(): bool
|
||||
{
|
||||
return self::$file !== null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Starts gathering the information for code coverage.
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public static function start(string $file, string $engine): void
|
||||
{
|
||||
if (self::isStarted()) {
|
||||
throw new \LogicException('Code coverage collector has been already started.');
|
||||
|
||||
} elseif (!in_array($engine, self::detectEngines(), true)) {
|
||||
throw new \LogicException("Code coverage engine '$engine' is not supported.");
|
||||
}
|
||||
|
||||
self::$file = fopen($file, 'c+');
|
||||
self::$engine = $engine;
|
||||
self::{'start' . $engine}();
|
||||
|
||||
register_shutdown_function(function (): void {
|
||||
register_shutdown_function([__CLASS__, 'save']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Flushes all gathered information. Effective only with PHPDBG collector.
|
||||
*/
|
||||
public static function flush(): void
|
||||
{
|
||||
if (self::isStarted() && self::$engine === self::ENGINE_PHPDBG) {
|
||||
self::save();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Saves information about code coverage. Can be called repeatedly to free memory.
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public static function save(): void
|
||||
{
|
||||
if (!self::isStarted()) {
|
||||
throw new \LogicException('Code coverage collector has not been started.');
|
||||
}
|
||||
|
||||
[$positive, $negative] = self::{'collect' . self::$engine}();
|
||||
|
||||
flock(self::$file, LOCK_EX);
|
||||
fseek(self::$file, 0);
|
||||
$rawContent = stream_get_contents(self::$file);
|
||||
$original = $rawContent ? unserialize($rawContent) : [];
|
||||
$coverage = array_replace_recursive($negative, $original, $positive);
|
||||
|
||||
fseek(self::$file, 0);
|
||||
ftruncate(self::$file, 0);
|
||||
fwrite(self::$file, serialize($coverage));
|
||||
flock(self::$file, LOCK_UN);
|
||||
}
|
||||
|
||||
|
||||
private static function startPCOV(): void
|
||||
{
|
||||
\pcov\start();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Collects information about code coverage.
|
||||
*/
|
||||
private static function collectPCOV(): array
|
||||
{
|
||||
$positive = $negative = [];
|
||||
|
||||
\pcov\stop();
|
||||
|
||||
foreach (\pcov\collect() as $file => $lines) {
|
||||
if (!file_exists($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($lines as $num => $val) {
|
||||
if ($val > 0) {
|
||||
$positive[$file][$num] = $val;
|
||||
} else {
|
||||
$negative[$file][$num] = $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$positive, $negative];
|
||||
}
|
||||
|
||||
|
||||
private static function startXdebug(): void
|
||||
{
|
||||
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Collects information about code coverage.
|
||||
*/
|
||||
private static function collectXdebug(): array
|
||||
{
|
||||
$positive = $negative = [];
|
||||
|
||||
foreach (xdebug_get_code_coverage() as $file => $lines) {
|
||||
if (!file_exists($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($lines as $num => $val) {
|
||||
if ($val > 0) {
|
||||
$positive[$file][$num] = $val;
|
||||
} else {
|
||||
$negative[$file][$num] = $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$positive, $negative];
|
||||
}
|
||||
|
||||
|
||||
private static function startPhpDbg(): void
|
||||
{
|
||||
phpdbg_start_oplog();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Collects information about code coverage.
|
||||
*/
|
||||
private static function collectPhpDbg(): array
|
||||
{
|
||||
$positive = phpdbg_end_oplog();
|
||||
$negative = phpdbg_get_executable();
|
||||
|
||||
foreach ($positive as $file => &$lines) {
|
||||
$lines = array_fill_keys(array_keys($lines), 1);
|
||||
}
|
||||
|
||||
foreach ($negative as $file => &$lines) {
|
||||
$lines = array_fill_keys(array_keys($lines), -1);
|
||||
}
|
||||
|
||||
phpdbg_start_oplog();
|
||||
return [$positive, $negative];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\CodeCoverage\Generators;
|
||||
|
||||
use Tester\Helpers;
|
||||
|
||||
|
||||
/**
|
||||
* Code coverage report generator.
|
||||
*/
|
||||
abstract class AbstractGenerator
|
||||
{
|
||||
protected const
|
||||
CODE_DEAD = -2,
|
||||
CODE_UNTESTED = -1,
|
||||
CODE_TESTED = 1;
|
||||
|
||||
/** @var array */
|
||||
public $acceptFiles = ['php', 'phpt', 'phtml'];
|
||||
|
||||
/** @var array */
|
||||
protected $data;
|
||||
|
||||
/** @var array */
|
||||
protected $sources;
|
||||
|
||||
/** @var int */
|
||||
protected $totalSum = 0;
|
||||
|
||||
/** @var int */
|
||||
protected $coveredSum = 0;
|
||||
|
||||
|
||||
/**
|
||||
* @param string $file path to coverage.dat file
|
||||
* @param array $sources paths to covered source files or directories
|
||||
*/
|
||||
public function __construct(string $file, array $sources = [])
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
throw new \Exception("File '$file' is missing.");
|
||||
}
|
||||
|
||||
$this->data = @unserialize(file_get_contents($file)); // @ is escalated to exception
|
||||
if (!is_array($this->data)) {
|
||||
throw new \Exception("Content of file '$file' is invalid.");
|
||||
}
|
||||
|
||||
$this->data = array_filter($this->data, function (string $path): bool {
|
||||
return @is_file($path); // @ some files or wrappers may not exist, i.e. mock://
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
if (!$sources) {
|
||||
$sources = [Helpers::findCommonDirectory(array_keys($this->data))];
|
||||
|
||||
} else {
|
||||
foreach ($sources as $source) {
|
||||
if (!file_exists($source)) {
|
||||
throw new \Exception("File or directory '$source' is missing.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->sources = array_map('realpath', $sources);
|
||||
}
|
||||
|
||||
|
||||
public function render(string $file = null): void
|
||||
{
|
||||
$handle = $file ? @fopen($file, 'w') : STDOUT; // @ is escalated to exception
|
||||
if (!$handle) {
|
||||
throw new \Exception("Unable to write to file '$file'.");
|
||||
}
|
||||
|
||||
ob_start(function (string $buffer) use ($handle) { fwrite($handle, $buffer); }, 4096);
|
||||
try {
|
||||
$this->renderSelf();
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
ob_end_flush();
|
||||
fclose($handle);
|
||||
|
||||
if (isset($e)) {
|
||||
if ($file) {
|
||||
unlink($file);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function getCoveredPercent(): float
|
||||
{
|
||||
return $this->totalSum ? $this->coveredSum * 100 / $this->totalSum : 0;
|
||||
}
|
||||
|
||||
|
||||
protected function getSourceIterator(): \Iterator
|
||||
{
|
||||
$iterator = new \AppendIterator;
|
||||
foreach ($this->sources as $source) {
|
||||
$iterator->append(
|
||||
is_dir($source)
|
||||
? new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source))
|
||||
: new \ArrayIterator([new \SplFileInfo($source)])
|
||||
);
|
||||
}
|
||||
|
||||
return new \CallbackFilterIterator($iterator, function (\SplFileInfo $file): bool {
|
||||
return $file->getBasename()[0] !== '.' // . or .. or .gitignore
|
||||
&& in_array($file->getExtension(), $this->acceptFiles, true);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** @deprecated */
|
||||
protected static function getCommonFilesPath(array $files): string
|
||||
{
|
||||
return Helpers::findCommonDirectory($files);
|
||||
}
|
||||
|
||||
|
||||
abstract protected function renderSelf(): void;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\CodeCoverage\Generators;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use Tester\CodeCoverage\PhpParser;
|
||||
|
||||
|
||||
class CloverXMLGenerator extends AbstractGenerator
|
||||
{
|
||||
private static $metricAttributesMap = [
|
||||
'packageCount' => 'packages',
|
||||
'fileCount' => 'files',
|
||||
'linesOfCode' => 'loc',
|
||||
'linesOfNonCommentedCode' => 'ncloc',
|
||||
'classCount' => 'classes',
|
||||
'methodCount' => 'methods',
|
||||
'coveredMethodCount' => 'coveredmethods',
|
||||
'statementCount' => 'statements',
|
||||
'coveredStatementCount' => 'coveredstatements',
|
||||
'elementCount' => 'elements',
|
||||
'coveredElementCount' => 'coveredelements',
|
||||
'conditionalCount' => 'conditionals',
|
||||
'coveredConditionalCount' => 'coveredconditionals',
|
||||
];
|
||||
|
||||
|
||||
public function __construct(string $file, array $sources = [])
|
||||
{
|
||||
if (!extension_loaded('dom')) {
|
||||
throw new \LogicException('CloverXML generator requires DOM extension to be loaded.');
|
||||
}
|
||||
parent::__construct($file, $sources);
|
||||
}
|
||||
|
||||
|
||||
protected function renderSelf(): void
|
||||
{
|
||||
$time = (string) time();
|
||||
$parser = new PhpParser;
|
||||
|
||||
$doc = new DOMDocument;
|
||||
$doc->formatOutput = true;
|
||||
|
||||
$elCoverage = $doc->appendChild($doc->createElement('coverage'));
|
||||
$elCoverage->setAttribute('generated', $time);
|
||||
|
||||
// TODO: @name
|
||||
$elProject = $elCoverage->appendChild($doc->createElement('project'));
|
||||
$elProject->setAttribute('timestamp', $time);
|
||||
$elProjectMetrics = $elProject->appendChild($doc->createElement('metrics'));
|
||||
|
||||
$projectMetrics = (object) [
|
||||
'packageCount' => 0,
|
||||
'fileCount' => 0,
|
||||
'linesOfCode' => 0,
|
||||
'linesOfNonCommentedCode' => 0,
|
||||
'classCount' => 0,
|
||||
'methodCount' => 0,
|
||||
'coveredMethodCount' => 0,
|
||||
'statementCount' => 0,
|
||||
'coveredStatementCount' => 0,
|
||||
'elementCount' => 0,
|
||||
'coveredElementCount' => 0,
|
||||
'conditionalCount' => 0,
|
||||
'coveredConditionalCount' => 0,
|
||||
];
|
||||
|
||||
foreach ($this->getSourceIterator() as $file) {
|
||||
$file = (string) $file;
|
||||
|
||||
$projectMetrics->fileCount++;
|
||||
|
||||
if (empty($this->data[$file])) {
|
||||
$coverageData = null;
|
||||
$this->totalSum += count(file($file, FILE_SKIP_EMPTY_LINES));
|
||||
} else {
|
||||
$coverageData = $this->data[$file];
|
||||
}
|
||||
|
||||
// TODO: split to <package> by namespace?
|
||||
$elFile = $elProject->appendChild($doc->createElement('file'));
|
||||
$elFile->setAttribute('name', $file);
|
||||
$elFileMetrics = $elFile->appendChild($doc->createElement('metrics'));
|
||||
|
||||
$code = $parser->parse(file_get_contents($file));
|
||||
|
||||
$fileMetrics = (object) [
|
||||
'linesOfCode' => $code->linesOfCode,
|
||||
'linesOfNonCommentedCode' => $code->linesOfCode - $code->linesOfComments,
|
||||
'classCount' => count($code->classes) + count($code->traits),
|
||||
'methodCount' => 0,
|
||||
'coveredMethodCount' => 0,
|
||||
'statementCount' => 0,
|
||||
'coveredStatementCount' => 0,
|
||||
'elementCount' => 0,
|
||||
'coveredElementCount' => 0,
|
||||
'conditionalCount' => 0,
|
||||
'coveredConditionalCount' => 0,
|
||||
];
|
||||
|
||||
foreach (array_merge($code->classes, $code->traits) as $name => $info) { // TODO: interfaces?
|
||||
$elClass = $elFile->appendChild($doc->createElement('class'));
|
||||
if (($tmp = strrpos($name, '\\')) === false) {
|
||||
$elClass->setAttribute('name', $name);
|
||||
} else {
|
||||
$elClass->setAttribute('namespace', substr($name, 0, $tmp));
|
||||
$elClass->setAttribute('name', substr($name, $tmp + 1));
|
||||
}
|
||||
|
||||
$elClassMetrics = $elClass->appendChild($doc->createElement('metrics'));
|
||||
$classMetrics = $this->calculateClassMetrics($info, $coverageData);
|
||||
self::setMetricAttributes($elClassMetrics, $classMetrics);
|
||||
self::appendMetrics($fileMetrics, $classMetrics);
|
||||
}
|
||||
self::setMetricAttributes($elFileMetrics, $fileMetrics);
|
||||
|
||||
|
||||
foreach ((array) $coverageData as $line => $count) {
|
||||
if ($count === self::CODE_DEAD) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Line type can be 'method' but Xdebug does not report such lines as executed.
|
||||
$elLine = $elFile->appendChild($doc->createElement('line'));
|
||||
$elLine->setAttribute('num', (string) $line);
|
||||
$elLine->setAttribute('type', 'stmt');
|
||||
$elLine->setAttribute('count', (string) max(0, $count));
|
||||
|
||||
$this->totalSum++;
|
||||
$this->coveredSum += $count > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
self::appendMetrics($projectMetrics, $fileMetrics);
|
||||
}
|
||||
|
||||
// TODO: What about reported (covered) lines outside of class/trait definition?
|
||||
self::setMetricAttributes($elProjectMetrics, $projectMetrics);
|
||||
|
||||
echo $doc->saveXML();
|
||||
}
|
||||
|
||||
|
||||
private function calculateClassMetrics(\stdClass $info, array $coverageData = null): \stdClass
|
||||
{
|
||||
$stats = (object) [
|
||||
'methodCount' => count($info->methods),
|
||||
'coveredMethodCount' => 0,
|
||||
'statementCount' => 0,
|
||||
'coveredStatementCount' => 0,
|
||||
'conditionalCount' => 0,
|
||||
'coveredConditionalCount' => 0,
|
||||
'elementCount' => null,
|
||||
'coveredElementCount' => null,
|
||||
];
|
||||
|
||||
foreach ($info->methods as $name => $methodInfo) {
|
||||
[$lineCount, $coveredLineCount] = $this->analyzeMethod($methodInfo, $coverageData);
|
||||
|
||||
$stats->statementCount += $lineCount;
|
||||
|
||||
if ($coverageData !== null) {
|
||||
$stats->coveredMethodCount += $lineCount === $coveredLineCount ? 1 : 0;
|
||||
$stats->coveredStatementCount += $coveredLineCount;
|
||||
}
|
||||
}
|
||||
|
||||
$stats->elementCount = $stats->methodCount + $stats->statementCount;
|
||||
$stats->coveredElementCount = $stats->coveredMethodCount + $stats->coveredStatementCount;
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
|
||||
private static function analyzeMethod(\stdClass $info, array $coverageData = null): array
|
||||
{
|
||||
$count = 0;
|
||||
$coveredCount = 0;
|
||||
|
||||
if ($coverageData === null) { // Never loaded file
|
||||
$count = max(1, $info->end - $info->start - 2);
|
||||
} else {
|
||||
for ($i = $info->start; $i <= $info->end; $i++) {
|
||||
if (isset($coverageData[$i]) && $coverageData[$i] !== self::CODE_DEAD) {
|
||||
$count++;
|
||||
if ($coverageData[$i] > 0) {
|
||||
$coveredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [$count, $coveredCount];
|
||||
}
|
||||
|
||||
|
||||
private static function appendMetrics(\stdClass $summary, \stdClass $add): void
|
||||
{
|
||||
foreach ($add as $name => $value) {
|
||||
$summary->{$name} += $value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static function setMetricAttributes(DOMElement $element, \stdClass $metrics): void
|
||||
{
|
||||
foreach ($metrics as $name => $value) {
|
||||
$element->setAttribute(self::$metricAttributesMap[$name], (string) $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\CodeCoverage\Generators;
|
||||
|
||||
use Tester\Helpers;
|
||||
|
||||
|
||||
/**
|
||||
* Code coverage report generator.
|
||||
*/
|
||||
class HtmlGenerator extends AbstractGenerator
|
||||
{
|
||||
private const CLASSES = [
|
||||
self::CODE_TESTED => 't', // tested
|
||||
self::CODE_UNTESTED => 'u', // untested
|
||||
self::CODE_DEAD => 'dead', // dead code
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
private $title;
|
||||
|
||||
/** @var array */
|
||||
private $files = [];
|
||||
|
||||
|
||||
/**
|
||||
* @param string $file path to coverage.dat file
|
||||
* @param array $sources files/directories
|
||||
*/
|
||||
public function __construct(string $file, array $sources = [], string $title = null)
|
||||
{
|
||||
parent::__construct($file, $sources);
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
|
||||
protected function renderSelf(): void
|
||||
{
|
||||
$this->setupHighlight();
|
||||
$this->parse();
|
||||
|
||||
$title = $this->title;
|
||||
$classes = self::CLASSES;
|
||||
$files = $this->files;
|
||||
$coveredPercent = $this->getCoveredPercent();
|
||||
|
||||
include __DIR__ . '/template.phtml';
|
||||
}
|
||||
|
||||
|
||||
private function setupHighlight(): void
|
||||
{
|
||||
ini_set('highlight.comment', 'hc');
|
||||
ini_set('highlight.default', 'hd');
|
||||
ini_set('highlight.html', 'hh');
|
||||
ini_set('highlight.keyword', 'hk');
|
||||
ini_set('highlight.string', 'hs');
|
||||
}
|
||||
|
||||
|
||||
private function parse(): void
|
||||
{
|
||||
if (count($this->files) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->files = [];
|
||||
$commonSourcesPath = Helpers::findCommonDirectory($this->sources) . DIRECTORY_SEPARATOR;
|
||||
foreach ($this->getSourceIterator() as $entry) {
|
||||
$entry = (string) $entry;
|
||||
|
||||
$coverage = $covered = $total = 0;
|
||||
$loaded = !empty($this->data[$entry]);
|
||||
$lines = [];
|
||||
if ($loaded) {
|
||||
$lines = $this->data[$entry];
|
||||
foreach ($lines as $flag) {
|
||||
if ($flag >= self::CODE_UNTESTED) {
|
||||
$total++;
|
||||
}
|
||||
if ($flag >= self::CODE_TESTED) {
|
||||
$covered++;
|
||||
}
|
||||
}
|
||||
$coverage = round($covered * 100 / $total);
|
||||
$this->totalSum += $total;
|
||||
$this->coveredSum += $covered;
|
||||
} else {
|
||||
$this->totalSum += count(file($entry, FILE_SKIP_EMPTY_LINES));
|
||||
}
|
||||
|
||||
$light = $total ? $total < 5 : count(file($entry)) < 50;
|
||||
$this->files[] = (object) [
|
||||
'name' => str_replace($commonSourcesPath, '', $entry),
|
||||
'file' => $entry,
|
||||
'lines' => $lines,
|
||||
'coverage' => $coverage,
|
||||
'total' => $total,
|
||||
'class' => $light ? 'light' : ($loaded ? null : 'not-loaded'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="robots" content="noindex,noarchive">
|
||||
<meta name="generator" content="Nette Tester">
|
||||
|
||||
<title><?= $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage</title>
|
||||
|
||||
<style type="text/css">
|
||||
html {
|
||||
font: 14px/1.5 Verdana,"Geneva CE",lucida,sans-serif;
|
||||
border-top: 4.7em solid #f4ebdb;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 990px;
|
||||
margin: -4.7em auto 0;
|
||||
background: #fcfaf5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-left: .5em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Trebuchet MS","Geneva CE",lucida,sans-serif;
|
||||
font-size: 1.9em;
|
||||
margin: .5em .5em 1.5em;
|
||||
color: #7a7772;
|
||||
text-shadow: 1px 1px 0 white;
|
||||
}
|
||||
|
||||
div.code {
|
||||
background: white;
|
||||
border: 1px dotted silver;
|
||||
padding: .4em 0;
|
||||
display: none;
|
||||
color: #333;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
code,
|
||||
div.code {
|
||||
font: 13px/1.3 monospace;
|
||||
}
|
||||
|
||||
div.code > div {
|
||||
float: left;
|
||||
min-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
aside {
|
||||
min-width: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
aside div {
|
||||
white-space: pre;
|
||||
padding-left: .7em;
|
||||
}
|
||||
|
||||
aside a {
|
||||
color: #c0c0c0;
|
||||
}
|
||||
|
||||
aside a:hover {
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #006aeb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:active,
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
small {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.number {
|
||||
text-align: right;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
border: 1px solid #acacac;
|
||||
background: #e50400;
|
||||
width: 35px;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.bar div {
|
||||
background: #1a7e1e;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.light td {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.light td * {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.not-loaded td * {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.t { background-color: #e0ffe0; }
|
||||
.u { background-color: #ffe0e0; }
|
||||
|
||||
code .hc { color: #929292; }
|
||||
code .hd { color: #333; }
|
||||
code .hh { color: #06B; }
|
||||
code .hk { color: #e71818; }
|
||||
code .hs { color: #008000; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1><?= $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage <?= round($coveredPercent) ?> %</h1>
|
||||
|
||||
<?php foreach ($files as $id => $info): ?>
|
||||
<div>
|
||||
<table>
|
||||
<tr<?= $info->class ? " class='$info->class'" : '' ?>>
|
||||
<td class="number"><small><?= $info->coverage ?> %</small></td>
|
||||
<td><div class="bar"><div style="width: <?= $info->coverage ?>%"></div></div></td>
|
||||
<td><a href="#F<?= $id ?>" class="toggle"><?= $info->name ?></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="code" id="F<?= $id ?>">
|
||||
<div>
|
||||
<aside>
|
||||
<?php
|
||||
$code = file_get_contents($info->file);
|
||||
$lineCount = substr_count($code, "\n") + 1;
|
||||
$digits = ceil(log10($lineCount)) + 1;
|
||||
|
||||
$prevClass = null;
|
||||
$closeTag = $buffer = '';
|
||||
for ($i = 1; $i < $lineCount; $i++) {
|
||||
$class = isset($info->lines[$i]) && isset($classes[$info->lines[$i]]) ? $classes[$info->lines[$i]] : '';
|
||||
if ($class !== $prevClass) {
|
||||
echo rtrim($buffer) . $closeTag;
|
||||
$buffer = '';
|
||||
$closeTag = '</div>';
|
||||
echo '<div' . ($class ? " class='$class'" : '') . '>';
|
||||
}
|
||||
|
||||
$buffer .= "<a href='#F{$id}L{$i}' id='F{$id}L{$i}'>" . sprintf("%{$digits}s", $i) . "</a>\n";
|
||||
$prevClass = $class;
|
||||
}
|
||||
echo $buffer . $closeTag;
|
||||
|
||||
$code = strtr(highlight_string($code, true), [
|
||||
'<code>' => "<code style='margin-left: {$digits}em'>",
|
||||
'<span style="color: ' => '<span class="',
|
||||
]);
|
||||
?>
|
||||
</aside>
|
||||
<?= $code ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
|
||||
<footer>
|
||||
<p>Generated by <a href="https://tester.nette.org">Nette Tester</a> at <?= @date('Y/m/d H:i:s') // @ timezone may not be set ?></p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.body.addEventListener('click', function (e) {
|
||||
if (e.target.className === 'toggle') {
|
||||
var el = document.getElementById(e.target.href.split('#')[1]);
|
||||
if (el.style.display === 'block') {
|
||||
el.style.display = 'none';
|
||||
} else {
|
||||
el.style.display = 'block';
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
if (el = document.getElementById(window.location.hash.replace(/^#|L\d+$/g, ''))) {
|
||||
el.style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\CodeCoverage;
|
||||
|
||||
|
||||
/**
|
||||
* Parses PHP source code and returns:
|
||||
* - the start/end line information about functions, classes, interfaces, traits and their methods
|
||||
* - the count of code lines
|
||||
* - the count of commented code lines
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class PhpParser
|
||||
{
|
||||
/**
|
||||
* Returned structure is:
|
||||
* stdClass {
|
||||
* linesOfCode: int,
|
||||
* linesOfComments: int,
|
||||
* functions: [functionName => $functionInfo],
|
||||
* classes: [className => $info],
|
||||
* traits: [traitName => $info],
|
||||
* interfaces: [interfaceName => $info],
|
||||
* }
|
||||
*
|
||||
* where $functionInfo is:
|
||||
* stdClass {
|
||||
* start: int,
|
||||
* end: int
|
||||
* }
|
||||
*
|
||||
* and $info is:
|
||||
* stdClass {
|
||||
* start: int,
|
||||
* end: int,
|
||||
* methods: [methodName => $methodInfo]
|
||||
* }
|
||||
*
|
||||
* where $methodInfo is:
|
||||
* stdClass {
|
||||
* start: int,
|
||||
* end: int,
|
||||
* visibility: public|protected|private
|
||||
* }
|
||||
*/
|
||||
public function parse(string $code): \stdClass
|
||||
{
|
||||
$tokens = token_get_all($code, TOKEN_PARSE);
|
||||
|
||||
$level = $classLevel = $functionLevel = null;
|
||||
$namespace = '';
|
||||
$line = 1;
|
||||
|
||||
$result = (object) [
|
||||
'linesOfCode' => max(1, substr_count($code, "\n")),
|
||||
'linesOfComments' => 0,
|
||||
'functions' => [],
|
||||
'classes' => [],
|
||||
'traits' => [],
|
||||
'interfaces' => [],
|
||||
];
|
||||
|
||||
while ($token = current($tokens)) {
|
||||
next($tokens);
|
||||
if (is_array($token)) {
|
||||
$line = $token[2];
|
||||
}
|
||||
|
||||
switch (is_array($token) ? $token[0] : $token) {
|
||||
case T_NAMESPACE:
|
||||
$namespace = ltrim(self::fetch($tokens, [T_STRING, T_NS_SEPARATOR]) . '\\', '\\');
|
||||
break;
|
||||
|
||||
case T_CLASS:
|
||||
case T_INTERFACE:
|
||||
case T_TRAIT:
|
||||
if ($name = self::fetch($tokens, T_STRING)) {
|
||||
if ($token[0] === T_CLASS) {
|
||||
$class = &$result->classes[$namespace . $name];
|
||||
} elseif ($token[0] === T_INTERFACE) {
|
||||
$class = &$result->interfaces[$namespace . $name];
|
||||
} else {
|
||||
$class = &$result->traits[$namespace . $name];
|
||||
}
|
||||
|
||||
$classLevel = $level + 1;
|
||||
$class = (object) [
|
||||
'start' => $line,
|
||||
'end' => null,
|
||||
'methods' => [],
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
case T_PUBLIC:
|
||||
case T_PROTECTED:
|
||||
case T_PRIVATE:
|
||||
$visibility = $token[1];
|
||||
break;
|
||||
|
||||
case T_ABSTRACT:
|
||||
$isAbstract = true;
|
||||
break;
|
||||
|
||||
case T_FUNCTION:
|
||||
if (($name = self::fetch($tokens, T_STRING)) && !isset($isAbstract)) {
|
||||
if (isset($class) && $level === $classLevel) {
|
||||
$function = &$class->methods[$name];
|
||||
$function = (object) [
|
||||
'start' => $line,
|
||||
'end' => null,
|
||||
'visibility' => $visibility ?? 'public',
|
||||
];
|
||||
|
||||
} else {
|
||||
$function = &$result->functions[$namespace . $name];
|
||||
$function = (object) [
|
||||
'start' => $line,
|
||||
'end' => null,
|
||||
];
|
||||
}
|
||||
$functionLevel = $level + 1;
|
||||
}
|
||||
unset($visibility, $isAbstract);
|
||||
break;
|
||||
|
||||
case T_CURLY_OPEN:
|
||||
case T_DOLLAR_OPEN_CURLY_BRACES:
|
||||
case '{':
|
||||
$level++;
|
||||
break;
|
||||
|
||||
case '}':
|
||||
if (isset($function) && $level === $functionLevel) {
|
||||
$function->end = $line;
|
||||
unset($function);
|
||||
|
||||
} elseif (isset($class) && $level === $classLevel) {
|
||||
$class->end = $line;
|
||||
unset($class);
|
||||
}
|
||||
$level--;
|
||||
break;
|
||||
|
||||
case T_COMMENT:
|
||||
case T_DOC_COMMENT:
|
||||
$result->linesOfComments += substr_count(trim($token[1]), "\n") + 1;
|
||||
// break omitted
|
||||
|
||||
case T_WHITESPACE:
|
||||
case T_CONSTANT_ENCAPSED_STRING:
|
||||
$line += substr_count($token[1], "\n");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
private static function fetch(array &$tokens, $take): ?string
|
||||
{
|
||||
$res = null;
|
||||
while ($token = current($tokens)) {
|
||||
[$token, $s] = is_array($token) ? $token : [$token, $token];
|
||||
if (in_array($token, (array) $take, true)) {
|
||||
$res .= $s;
|
||||
} elseif (!in_array($token, [T_DOC_COMMENT, T_WHITESPACE, T_COMMENT], true)) {
|
||||
break;
|
||||
}
|
||||
next($tokens);
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* Assertion test helpers.
|
||||
*/
|
||||
class Assert
|
||||
{
|
||||
/** used by equal() for comparing floats */
|
||||
private const EPSILON = 1e-10;
|
||||
|
||||
/** used by match(); in values, each $ followed by number is backreference */
|
||||
public static $patterns = [
|
||||
'%%' => '%', // one % character
|
||||
'%a%' => '[^\r\n]+', // one or more of anything except the end of line characters
|
||||
'%a\?%' => '[^\r\n]*', // zero or more of anything except the end of line characters
|
||||
'%A%' => '.+', // one or more of anything including the end of line characters
|
||||
'%A\?%' => '.*', // zero or more of anything including the end of line characters
|
||||
'%s%' => '[\t ]+', // one or more white space characters except the end of line characters
|
||||
'%s\?%' => '[\t ]*', // zero or more white space characters except the end of line characters
|
||||
'%S%' => '\S+', // one or more of characters except the white space
|
||||
'%S\?%' => '\S*', // zero or more of characters except the white space
|
||||
'%c%' => '[^\r\n]', // a single character of any sort (except the end of line)
|
||||
'%d%' => '[0-9]+', // one or more digits
|
||||
'%d\?%' => '[0-9]*', // zero or more digits
|
||||
'%i%' => '[+-]?[0-9]+', // signed integer value
|
||||
'%f%' => '[+-]?\.?\d+\.?\d*(?:[Ee][+-]?\d+)?', // floating point number
|
||||
'%h%' => '[0-9a-fA-F]+', // one or more HEX digits
|
||||
'%w%' => '[0-9a-zA-Z_]+', //one or more alphanumeric characters
|
||||
'%ds%' => '[\\\\/]', // directory separator
|
||||
'%(\[.+\][+*?{},\d]*)%' => '$1', // range
|
||||
];
|
||||
|
||||
/** @var callable function (AssertException $exception): void */
|
||||
public static $onFailure;
|
||||
|
||||
/** @var int the count of assertions */
|
||||
public static $counter = 0;
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that two values are equal and have the same type and identity of objects.
|
||||
*/
|
||||
public static function same($expected, $actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if ($actual !== $expected) {
|
||||
self::fail(self::describe('%1 should be %2', $description), $actual, $expected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that two values are not equal or do not have the same type and identity of objects.
|
||||
*/
|
||||
public static function notSame($expected, $actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if ($actual === $expected) {
|
||||
self::fail(self::describe('%1 should not be %2', $description), $actual, $expected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that two values are equal and checks expectations. The identity of objects,
|
||||
* the order of keys in the arrays and marginally different floats are ignored.
|
||||
*/
|
||||
public static function equal($expected, $actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if (!self::isEqual($expected, $actual)) {
|
||||
self::fail(self::describe('%1 should be equal to %2', $description), $actual, $expected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that two values are not equal and checks expectations. The identity of objects,
|
||||
* the order of keys in the arrays and marginally different floats are ignored.
|
||||
*/
|
||||
public static function notEqual($expected, $actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
try {
|
||||
$res = self::isEqual($expected, $actual);
|
||||
} catch (AssertException $e) {
|
||||
}
|
||||
if (empty($e) && $res) {
|
||||
self::fail(self::describe('%1 should not be equal to %2', $description), $actual, $expected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a haystack (string or array) contains an expected needle.
|
||||
*/
|
||||
public static function contains($needle, $actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if (is_array($actual)) {
|
||||
if (!in_array($needle, $actual, true)) {
|
||||
self::fail(self::describe('%1 should contain %2', $description), $actual, $needle);
|
||||
}
|
||||
} elseif (is_string($actual)) {
|
||||
if ($needle !== '' && strpos($actual, $needle) === false) {
|
||||
self::fail(self::describe('%1 should contain %2', $description), $actual, $needle);
|
||||
}
|
||||
} else {
|
||||
self::fail(self::describe('%1 should be string or array', $description), $actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a haystack (string or array) does not contain an expected needle.
|
||||
*/
|
||||
public static function notContains($needle, $actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if (is_array($actual)) {
|
||||
if (in_array($needle, $actual, true)) {
|
||||
self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle);
|
||||
}
|
||||
} elseif (is_string($actual)) {
|
||||
if ($needle === '' || strpos($actual, $needle) !== false) {
|
||||
self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle);
|
||||
}
|
||||
} else {
|
||||
self::fail(self::describe('%1 should be string or array', $description), $actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a value is true.
|
||||
* @param mixed $actual
|
||||
*/
|
||||
public static function true($actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if ($actual !== true) {
|
||||
self::fail(self::describe('%1 should be TRUE', $description), $actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a value is false.
|
||||
* @param mixed $actual
|
||||
*/
|
||||
public static function false($actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if ($actual !== false) {
|
||||
self::fail(self::describe('%1 should be FALSE', $description), $actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a value is null.
|
||||
* @param mixed $actual
|
||||
*/
|
||||
public static function null($actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if ($actual !== null) {
|
||||
self::fail(self::describe('%1 should be NULL', $description), $actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a value is Not a Number.
|
||||
* @param mixed $actual
|
||||
*/
|
||||
public static function nan($actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if (!is_float($actual) || !is_nan($actual)) {
|
||||
self::fail(self::describe('%1 should be NAN', $description), $actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a value is truthy.
|
||||
* @param mixed $actual
|
||||
*/
|
||||
public static function truthy($actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if (!$actual) {
|
||||
self::fail(self::describe('%1 should be truthy', $description), $actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a value is falsey.
|
||||
* @param mixed $actual
|
||||
*/
|
||||
public static function falsey($actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if ($actual) {
|
||||
self::fail(self::describe('%1 should be falsey', $description), $actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts the number of items in an array or Countable.
|
||||
* @param mixed $value
|
||||
*/
|
||||
public static function count(int $count, $value, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if (!$value instanceof \Countable && !is_array($value)) {
|
||||
self::fail(self::describe('%1 should be array or countable object', $description), $value);
|
||||
|
||||
} elseif (count($value) !== $count) {
|
||||
self::fail(self::describe('Count %1 should be %2', $description), count($value), $count);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a value is of given class, interface or built-in type.
|
||||
* @param string|object $type
|
||||
* @param mixed $value
|
||||
*/
|
||||
public static function type($type, $value, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if (!is_object($type) && !is_string($type)) {
|
||||
throw new \Exception('Type must be a object or a string.');
|
||||
|
||||
} elseif ($type === 'list') {
|
||||
if (!is_array($value) || ($value && array_keys($value) !== range(0, count($value) - 1))) {
|
||||
self::fail(self::describe("%1 should be $type", $description), $value);
|
||||
}
|
||||
|
||||
} elseif (in_array($type, ['array', 'bool', 'callable', 'float',
|
||||
'int', 'integer', 'null', 'object', 'resource', 'scalar', 'string', ], true)
|
||||
) {
|
||||
if (!("is_$type")($value)) {
|
||||
self::fail(self::describe(gettype($value) . " should be $type", $description));
|
||||
}
|
||||
|
||||
} elseif (!$value instanceof $type) {
|
||||
$actual = is_object($value) ? get_class($value) : gettype($value);
|
||||
self::fail(self::describe("$actual should be instance of $type", $description));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a function throws exception of given type and its message matches given pattern.
|
||||
*/
|
||||
public static function exception(callable $function, string $class, string $message = null, $code = null): ?\Throwable
|
||||
{
|
||||
self::$counter++;
|
||||
$e = null;
|
||||
try {
|
||||
$function();
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
if ($e === null) {
|
||||
self::fail("$class was expected, but none was thrown");
|
||||
|
||||
} elseif (!$e instanceof $class) {
|
||||
self::fail("$class was expected but got " . get_class($e) . ($e->getMessage() ? " ({$e->getMessage()})" : ''), null, null, $e);
|
||||
|
||||
} elseif ($message && !self::isMatching($message, $e->getMessage())) {
|
||||
self::fail("$class with a message matching %2 was expected but got %1", $e->getMessage(), $message);
|
||||
|
||||
} elseif ($code !== null && $e->getCode() !== $code) {
|
||||
self::fail("$class with a code %2 was expected but got %1", $e->getCode(), $code);
|
||||
}
|
||||
return $e;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a function throws exception of given type and its message matches given pattern. Alias for exception().
|
||||
*/
|
||||
public static function throws(callable $function, string $class, string $message = null, $code = null): ?\Throwable
|
||||
{
|
||||
return self::exception($function, $class, $message, $code);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a function generates one or more PHP errors or throws exceptions.
|
||||
* @param int|string|array $expectedType
|
||||
* @param string $expectedMessage message
|
||||
* @throws \Exception
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function error(callable $function, $expectedType, string $expectedMessage = null): ?\Throwable
|
||||
{
|
||||
if (is_string($expectedType) && !preg_match('#^E_[A-Z_]+\z#', $expectedType)) {
|
||||
return static::exception($function, $expectedType, $expectedMessage);
|
||||
}
|
||||
|
||||
self::$counter++;
|
||||
$expected = is_array($expectedType) ? $expectedType : [[$expectedType, $expectedMessage]];
|
||||
foreach ($expected as &$item) {
|
||||
$item = ((array) $item) + [null, null];
|
||||
$expectedType = $item[0];
|
||||
if (is_int($expectedType)) {
|
||||
$item[2] = Helpers::errorTypeToString($expectedType);
|
||||
} elseif (is_string($expectedType)) {
|
||||
$item[0] = constant($item[2] = $expectedType);
|
||||
} else {
|
||||
throw new \Exception('Error type must be E_* constant.');
|
||||
}
|
||||
}
|
||||
|
||||
set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$expected) {
|
||||
if (($severity & error_reporting()) !== $severity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$errorStr = Helpers::errorTypeToString($severity) . ($message ? " ($message)" : '');
|
||||
[$expectedType, $expectedMessage, $expectedTypeStr] = array_shift($expected);
|
||||
if ($expectedType === null) {
|
||||
self::fail("Generated more errors than expected: $errorStr was generated in file $file on line $line");
|
||||
|
||||
} elseif ($severity !== $expectedType) {
|
||||
self::fail("$expectedTypeStr was expected, but $errorStr was generated in file $file on line $line");
|
||||
|
||||
} elseif ($expectedMessage && !self::isMatching($expectedMessage, $message)) {
|
||||
self::fail("$expectedTypeStr with a message matching %2 was expected but got %1", $message, $expectedMessage);
|
||||
}
|
||||
});
|
||||
|
||||
reset($expected);
|
||||
try {
|
||||
$function();
|
||||
restore_error_handler();
|
||||
} catch (\Exception $e) {
|
||||
restore_error_handler();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($expected) {
|
||||
self::fail('Error was expected, but was not generated');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a function does not generate PHP errors and does not throw exceptions.
|
||||
*/
|
||||
public static function noError(callable $function): void
|
||||
{
|
||||
if (($count = func_num_args()) > 1) {
|
||||
throw new \Exception(__METHOD__ . "() expects 1 parameter, $count given.");
|
||||
}
|
||||
self::error($function, []);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a string matches a given pattern.
|
||||
* %a% one or more of anything except the end of line characters
|
||||
* %a?% zero or more of anything except the end of line characters
|
||||
* %A% one or more of anything including the end of line characters
|
||||
* %A?% zero or more of anything including the end of line characters
|
||||
* %s% one or more white space characters except the end of line characters
|
||||
* %s?% zero or more white space characters except the end of line characters
|
||||
* %S% one or more of characters except the white space
|
||||
* %S?% zero or more of characters except the white space
|
||||
* %c% a single character of any sort (except the end of line)
|
||||
* %d% one or more digits
|
||||
* %d?% zero or more digits
|
||||
* %i% signed integer value
|
||||
* %f% floating point number
|
||||
* %h% one or more HEX digits
|
||||
* @param string $pattern mask|regexp; only delimiters ~ and # are supported for regexp
|
||||
*/
|
||||
public static function match(string $pattern, $actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
if (!is_scalar($actual)) {
|
||||
self::fail(self::describe('%1 should match %2', $description), $actual, $pattern);
|
||||
|
||||
} elseif (!self::isMatching($pattern, $actual)) {
|
||||
[$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual);
|
||||
self::fail(self::describe('%1 should match %2', $description), $actual, $pattern);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asserts that a string matches a given pattern stored in file.
|
||||
*/
|
||||
public static function matchFile(string $file, $actual, string $description = null): void
|
||||
{
|
||||
self::$counter++;
|
||||
$pattern = @file_get_contents($file); // @ is escalated to exception
|
||||
if ($pattern === false) {
|
||||
throw new \Exception("Unable to read file '$file'.");
|
||||
|
||||
} elseif (!is_scalar($actual)) {
|
||||
self::fail(self::describe('%1 should match %2', $description), $actual, $pattern);
|
||||
|
||||
} elseif (!self::isMatching($pattern, $actual)) {
|
||||
[$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual);
|
||||
self::fail(self::describe('%1 should match %2', $description), $actual, $pattern);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Assertion that fails.
|
||||
*/
|
||||
public static function fail(string $message, $actual = null, $expected = null, \Throwable $previous = null): void
|
||||
{
|
||||
$e = new AssertException($message, $expected, $actual, $previous);
|
||||
if (self::$onFailure) {
|
||||
(self::$onFailure)($e);
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static function describe(string $reason, string $description = null): string
|
||||
{
|
||||
return ($description ? $description . ': ' : '') . $reason;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Executes function that can access private and protected members of given object via $this.
|
||||
* @param object|string $obj
|
||||
*/
|
||||
public static function with($objectOrClass, \Closure $closure)
|
||||
{
|
||||
return $closure->bindTo(is_object($objectOrClass) ? $objectOrClass : null, $objectOrClass)();
|
||||
}
|
||||
|
||||
|
||||
/********************* helpers ****************d*g**/
|
||||
|
||||
|
||||
/**
|
||||
* Compares using mask.
|
||||
* @internal
|
||||
*/
|
||||
public static function isMatching(string $pattern, $actual, bool $strict = false): bool
|
||||
{
|
||||
if (!is_scalar($actual)) {
|
||||
throw new \Exception('Value must be strings.');
|
||||
}
|
||||
|
||||
$old = ini_set('pcre.backtrack_limit', '10000000');
|
||||
|
||||
if (!self::isPcre($pattern)) {
|
||||
$utf8 = preg_match('#\x80-\x{10FFFF}]#u', $pattern) ? 'u' : '';
|
||||
$suffix = ($strict ? '\z#sU' : '\s*$#sU') . $utf8;
|
||||
$patterns = static::$patterns + [
|
||||
'[.\\\\+*?[^$(){|\#]' => '\$0', // preg quoting
|
||||
'\x00' => '\x00',
|
||||
'[\t ]*\r?\n' => '[\t ]*\r?\n', // right trim
|
||||
];
|
||||
$pattern = '#^' . preg_replace_callback('#' . implode('|', array_keys($patterns)) . '#U' . $utf8, function ($m) use ($patterns) {
|
||||
foreach ($patterns as $re => $replacement) {
|
||||
$s = preg_replace("#^$re\\z#", str_replace('\\', '\\\\', $replacement), $m[0], 1, $count);
|
||||
if ($count) {
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
}, rtrim($pattern, " \t\n\r")) . $suffix;
|
||||
}
|
||||
|
||||
$res = preg_match($pattern, (string) $actual);
|
||||
ini_set('pcre.backtrack_limit', $old);
|
||||
if ($res === false || preg_last_error()) {
|
||||
throw new \Exception('Error while executing regular expression. (PREG Error Code ' . preg_last_error() . ')');
|
||||
}
|
||||
return (bool) $res;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function expandMatchingPatterns(string $pattern, $actual): array
|
||||
{
|
||||
if (self::isPcre($pattern)) {
|
||||
return [$pattern, $actual];
|
||||
}
|
||||
|
||||
$parts = preg_split('#(%)#', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
for ($i = count($parts); $i >= 0; $i--) {
|
||||
$patternX = implode(array_slice($parts, 0, $i));
|
||||
$patternY = "$patternX%A?%";
|
||||
if (self::isMatching($patternY, $actual)) {
|
||||
$patternZ = implode(array_slice($parts, $i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['%A%', '%A?%'] as $greedyPattern) {
|
||||
if (substr($patternX, -strlen($greedyPattern)) === $greedyPattern) {
|
||||
$patternX = substr($patternX, 0, -strlen($greedyPattern));
|
||||
$patternY = "$patternX%A?%";
|
||||
$patternZ = $greedyPattern . $patternZ;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$low = 0;
|
||||
$high = strlen($actual);
|
||||
while ($low <= $high) {
|
||||
$mid = ($low + $high) >> 1;
|
||||
if (self::isMatching($patternY, substr($actual, 0, $mid))) {
|
||||
$high = $mid - 1;
|
||||
} else {
|
||||
$low = $mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$low = $high + 2;
|
||||
$high = strlen($actual);
|
||||
while ($low <= $high) {
|
||||
$mid = ($low + $high) >> 1;
|
||||
if (!self::isMatching($patternX, substr($actual, 0, $mid), true)) {
|
||||
$high = $mid - 1;
|
||||
} else {
|
||||
$low = $mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$actualX = substr($actual, 0, $high);
|
||||
$actualZ = substr($actual, $high);
|
||||
|
||||
return [
|
||||
$actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $patternZ)),
|
||||
$actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $actualZ)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Compares two structures and checks expectations. The identity of objects, the order of keys
|
||||
* in the arrays and marginally different floats are ignored.
|
||||
*/
|
||||
private static function isEqual($expected, $actual, int $level = 0, $objects = null): bool
|
||||
{
|
||||
switch (true) {
|
||||
case $level > 10:
|
||||
throw new \Exception('Nesting level too deep or recursive dependency.');
|
||||
|
||||
case $expected instanceof Expect:
|
||||
$expected($actual);
|
||||
return true;
|
||||
|
||||
case is_float($expected) && is_float($actual) && is_finite($expected) && is_finite($actual):
|
||||
$diff = abs($expected - $actual);
|
||||
return ($diff < self::EPSILON) || ($diff / max(abs($expected), abs($actual)) < self::EPSILON);
|
||||
|
||||
case is_object($expected) && is_object($actual) && get_class($expected) === get_class($actual):
|
||||
$objects = $objects ? clone $objects : new \SplObjectStorage;
|
||||
if (isset($objects[$expected])) {
|
||||
return $objects[$expected] === $actual;
|
||||
} elseif ($expected === $actual) {
|
||||
return true;
|
||||
}
|
||||
$objects[$expected] = $actual;
|
||||
$objects[$actual] = $expected;
|
||||
$expected = (array) $expected;
|
||||
$actual = (array) $actual;
|
||||
// break omitted
|
||||
|
||||
case is_array($expected) && is_array($actual):
|
||||
ksort($expected, SORT_STRING);
|
||||
ksort($actual, SORT_STRING);
|
||||
if (array_keys($expected) !== array_keys($actual)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($expected as $value) {
|
||||
if (!self::isEqual($value, current($actual), $level + 1, $objects)) {
|
||||
return false;
|
||||
}
|
||||
next($actual);
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
return $expected === $actual;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static function isPcre(string $pattern): bool
|
||||
{
|
||||
return (bool) preg_match('/^([~#]).+(\1)[imsxUu]*\z/s', $pattern);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* Assertion exception.
|
||||
*/
|
||||
class AssertException extends \Exception
|
||||
{
|
||||
public $origMessage;
|
||||
|
||||
public $actual;
|
||||
|
||||
public $expected;
|
||||
|
||||
|
||||
public function __construct(string $message, $expected, $actual, \Throwable $previous = null)
|
||||
{
|
||||
parent::__construct('', 0, $previous);
|
||||
$this->expected = $expected;
|
||||
$this->actual = $actual;
|
||||
$this->setMessage($message);
|
||||
}
|
||||
|
||||
|
||||
public function setMessage(string $message): self
|
||||
{
|
||||
$this->origMessage = $message;
|
||||
$this->message = strtr($message, [
|
||||
'%1' => Dumper::toLine($this->actual),
|
||||
'%2' => Dumper::toLine($this->expected),
|
||||
]);
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* Data provider helpers.
|
||||
*/
|
||||
class DataProvider
|
||||
{
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function load(string $file, string $query = ''): array
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
throw new \Exception("Missing data-provider file '$file'.");
|
||||
}
|
||||
|
||||
if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
|
||||
$data = (function () {
|
||||
return require func_get_arg(0);
|
||||
})(realpath($file));
|
||||
|
||||
if ($data instanceof \Traversable) {
|
||||
$data = iterator_to_array($data);
|
||||
} elseif (!is_array($data)) {
|
||||
throw new \Exception("Data provider file '$file' did not return array or Traversable.");
|
||||
}
|
||||
|
||||
} else {
|
||||
$data = @parse_ini_file($file, true); // @ is escalated to exception
|
||||
if ($data === false) {
|
||||
throw new \Exception("Cannot parse data-provider file '$file'.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (!self::testQuery($key, $query)) {
|
||||
unset($data[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$data) {
|
||||
throw new \Exception("No records in data-provider file '$file'" . ($query ? " for query '$query'" : '') . '.');
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
public static function testQuery(string $input, string $query): bool
|
||||
{
|
||||
static $replaces = ['' => '=', '=>' => '>=', '=<' => '<='];
|
||||
$tokens = preg_split('#\s+#', $input);
|
||||
preg_match_all('#\s*,?\s*(<=|=<|<|==|=|!=|<>|>=|=>|>)?\s*([^\s,]+)#A', $query, $queryParts, PREG_SET_ORDER);
|
||||
foreach ($queryParts as [, $operator, $operand]) {
|
||||
$operator = $replaces[$operator] ?? $operator;
|
||||
$token = (string) array_shift($tokens);
|
||||
$res = preg_match('#^[0-9.]+\z#', $token)
|
||||
? version_compare($token, $operand, $operator)
|
||||
: self::compare($token, $operator, $operand);
|
||||
if (!$res) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
private static function compare($l, string $operator, $r): bool
|
||||
{
|
||||
switch ($operator) {
|
||||
case '>':
|
||||
return $l > $r;
|
||||
case '=>':
|
||||
case '>=':
|
||||
return $l >= $r;
|
||||
case '<':
|
||||
return $l < $r;
|
||||
case '=<':
|
||||
case '<=':
|
||||
return $l <= $r;
|
||||
case '=':
|
||||
case '==':
|
||||
return $l == $r;
|
||||
case '!':
|
||||
case '!=':
|
||||
case '<>':
|
||||
return $l != $r;
|
||||
}
|
||||
throw new \InvalidArgumentException("Unknown operator $operator.");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function parseAnnotation(string $annotation, string $file): array
|
||||
{
|
||||
if (!preg_match('#^(\??)\s*([^,\s]+)\s*,?\s*(\S.*)?()#', $annotation, $m)) {
|
||||
throw new \Exception("Invalid @dataProvider value '$annotation'.");
|
||||
}
|
||||
return [dirname($file) . DIRECTORY_SEPARATOR . $m[2], $m[3], (bool) $m[1]];
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* DomQuery simplifies querying (X)HTML documents.
|
||||
*/
|
||||
class DomQuery extends \SimpleXMLElement
|
||||
{
|
||||
public static function fromHtml(string $html): self
|
||||
{
|
||||
if (strpos($html, '<') === false) {
|
||||
$html = '<body>' . $html;
|
||||
}
|
||||
|
||||
// parse these elements as void
|
||||
$html = preg_replace('#<(keygen|source|track|wbr)(?=\s|>)((?:"[^"]*"|\'[^\']*\'|[^"\'>])*+)(?<!/)>#', '<$1$2 />', $html);
|
||||
|
||||
// fix parsing of </ inside scripts
|
||||
$html = preg_replace_callback('#(<script(?=\s|>)(?:"[^"]*"|\'[^\']*\'|[^"\'>])*+>)(.*?)(</script>)#s', function (array $m): string {
|
||||
return $m[1] . str_replace('</', '<\/', $m[2]) . $m[3];
|
||||
}, $html);
|
||||
|
||||
$dom = new \DOMDocument();
|
||||
$old = libxml_use_internal_errors(true);
|
||||
libxml_clear_errors();
|
||||
$dom->loadHTML($html);
|
||||
$errors = libxml_get_errors();
|
||||
libxml_use_internal_errors($old);
|
||||
|
||||
$re = '#Tag (article|aside|audio|bdi|canvas|data|datalist|figcaption|figure|footer|header|keygen|main|mark'
|
||||
. '|meter|nav|output|picture|progress|rb|rp|rt|rtc|ruby|section|source|template|time|track|video|wbr) invalid#';
|
||||
foreach ($errors as $error) {
|
||||
if (!preg_match($re, $error->message)) {
|
||||
trigger_error(__METHOD__ . ": $error->message on line $error->line.", E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
return simplexml_import_dom($dom, __CLASS__);
|
||||
}
|
||||
|
||||
|
||||
public static function fromXml(string $xml): self
|
||||
{
|
||||
return simplexml_load_string($xml, __CLASS__);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns array of descendants filtered by a selector.
|
||||
* @return DomQuery[]
|
||||
*/
|
||||
public function find(string $selector): array
|
||||
{
|
||||
return $this->xpath(self::css2xpath($selector));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check the current document against a selector.
|
||||
*/
|
||||
public function has(string $selector): bool
|
||||
{
|
||||
return (bool) $this->find($selector);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Transforms CSS expression to XPath.
|
||||
*/
|
||||
public static function css2xpath(string $css): string
|
||||
{
|
||||
$xpath = '//*';
|
||||
preg_match_all('/
|
||||
([#.:]?)([a-z][a-z0-9_-]*)| # id, class, pseudoclass (1,2)
|
||||
\[
|
||||
([a-z0-9_-]+)
|
||||
(?:
|
||||
([~*^$]?)=(
|
||||
"[^"]*"|
|
||||
\'[^\']*\'|
|
||||
[^\]]+
|
||||
)
|
||||
)?
|
||||
\]| # [attr=val] (3,4,5)
|
||||
\s*([>,+~])\s*| # > , + ~ (6)
|
||||
(\s+)| # whitespace (7)
|
||||
(\*) # * (8)
|
||||
/ix', trim($css), $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $m) {
|
||||
if ($m[1] === '#') { // #ID
|
||||
$xpath .= "[@id='$m[2]']";
|
||||
} elseif ($m[1] === '.') { // .class
|
||||
$xpath .= "[contains(concat(' ', normalize-space(@class), ' '), ' $m[2] ')]";
|
||||
} elseif ($m[1] === ':') { // :pseudo-class
|
||||
throw new \InvalidArgumentException('Not implemented.');
|
||||
} elseif ($m[2]) { // tag
|
||||
$xpath = rtrim($xpath, '*') . $m[2];
|
||||
} elseif ($m[3]) { // [attribute]
|
||||
$attr = '@' . strtolower($m[3]);
|
||||
if (!isset($m[5])) {
|
||||
$xpath .= "[$attr]";
|
||||
continue;
|
||||
}
|
||||
$val = trim($m[5], '"\'');
|
||||
if ($m[4] === '') {
|
||||
$xpath .= "[$attr='$val']";
|
||||
} elseif ($m[4] === '~') {
|
||||
$xpath .= "[contains(concat(' ', normalize-space($attr), ' '), ' $val ')]";
|
||||
} elseif ($m[4] === '*') {
|
||||
$xpath .= "[contains($attr, '$val')]";
|
||||
} elseif ($m[4] === '^') {
|
||||
$xpath .= "[starts-with($attr, '$val')]";
|
||||
} elseif ($m[4] === '$') {
|
||||
$xpath .= "[substring($attr, string-length($attr)-0)='$val']";
|
||||
}
|
||||
} elseif ($m[6] === '>') {
|
||||
$xpath .= '/*';
|
||||
} elseif ($m[6] === ',') {
|
||||
$xpath .= '|//*';
|
||||
} elseif ($m[6] === '~') {
|
||||
$xpath .= '/following-sibling::*';
|
||||
} elseif ($m[6] === '+') {
|
||||
throw new \InvalidArgumentException('Not implemented.');
|
||||
} elseif ($m[7]) {
|
||||
$xpath .= '//*';
|
||||
}
|
||||
}
|
||||
return $xpath;
|
||||
}
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* Dumps PHP variables.
|
||||
*/
|
||||
class Dumper
|
||||
{
|
||||
public static $maxLength = 70;
|
||||
|
||||
public static $maxDepth = 10;
|
||||
|
||||
public static $dumpDir = 'output';
|
||||
|
||||
public static $maxPathSegments = 3;
|
||||
|
||||
|
||||
/**
|
||||
* Dumps information about a variable in readable format.
|
||||
* @param mixed $var variable to dump
|
||||
*/
|
||||
public static function toLine($var): string
|
||||
{
|
||||
static $table;
|
||||
if ($table === null) {
|
||||
foreach (array_merge(range("\x00", "\x1F"), range("\x7F", "\xFF")) as $ch) {
|
||||
$table[$ch] = '\x' . str_pad(dechex(ord($ch)), 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$table['\\'] = '\\\\';
|
||||
$table["\r"] = '\r';
|
||||
$table["\n"] = '\n';
|
||||
$table["\t"] = '\t';
|
||||
}
|
||||
|
||||
if (is_bool($var)) {
|
||||
return $var ? 'TRUE' : 'FALSE';
|
||||
|
||||
} elseif ($var === null) {
|
||||
return 'NULL';
|
||||
|
||||
} elseif (is_int($var)) {
|
||||
return "$var";
|
||||
|
||||
} elseif (is_float($var)) {
|
||||
return var_export($var, true);
|
||||
|
||||
} elseif (is_string($var)) {
|
||||
if (preg_match('#^(.{' . self::$maxLength . '}).#su', $var, $m)) {
|
||||
$var = "$m[1]...";
|
||||
} elseif (strlen($var) > self::$maxLength) {
|
||||
$var = substr($var, 0, self::$maxLength) . '...';
|
||||
}
|
||||
return preg_match('#[^\x09\x0A\x0D\x20-\x7E\xA0-\x{10FFFF}]#u', $var) || preg_last_error() ? '"' . strtr($var, $table) . '"' : "'$var'";
|
||||
|
||||
} elseif (is_array($var)) {
|
||||
$out = '';
|
||||
$counter = 0;
|
||||
foreach ($var as $k => &$v) {
|
||||
$out .= ($out === '' ? '' : ', ');
|
||||
if (strlen($out) > self::$maxLength) {
|
||||
$out .= '...';
|
||||
break;
|
||||
}
|
||||
$out .= ($k === $counter ? '' : self::toLine($k) . ' => ')
|
||||
. (is_array($v) && $v ? '[...]' : self::toLine($v));
|
||||
$counter = is_int($k) ? max($k + 1, $counter) : $counter;
|
||||
}
|
||||
return "[$out]";
|
||||
|
||||
} elseif ($var instanceof \Throwable) {
|
||||
return 'Exception ' . get_class($var) . ': ' . ($var->getCode() ? '#' . $var->getCode() . ' ' : '') . $var->getMessage();
|
||||
|
||||
} elseif ($var instanceof Expect) {
|
||||
return $var->dump();
|
||||
|
||||
} elseif (is_object($var)) {
|
||||
return self::objectToLine($var);
|
||||
|
||||
} elseif (is_resource($var)) {
|
||||
return 'resource(' . get_resource_type($var) . ')';
|
||||
|
||||
} else {
|
||||
return 'unknown type';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Formats object to line.
|
||||
* @param object $object
|
||||
*/
|
||||
private static function objectToLine($object): string
|
||||
{
|
||||
$line = get_class($object);
|
||||
if ($object instanceof \DateTime || $object instanceof \DateTimeInterface) {
|
||||
$line .= '(' . $object->format('Y-m-d H:i:s O') . ')';
|
||||
}
|
||||
|
||||
return $line . '(' . self::hash($object) . ')';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Dumps variable in PHP format.
|
||||
* @param mixed $var variable to dump
|
||||
*/
|
||||
public static function toPhp($var): string
|
||||
{
|
||||
return self::_toPhp($var);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns object's stripped hash.
|
||||
* @param object $object
|
||||
*/
|
||||
private static function hash($object): string
|
||||
{
|
||||
return '#' . substr(md5(spl_object_hash($object)), 0, 4);
|
||||
}
|
||||
|
||||
|
||||
private static function _toPhp(&$var, array &$list = [], int $level = 0, int &$line = 1): string
|
||||
{
|
||||
if (is_float($var)) {
|
||||
$var = str_replace(',', '.', "$var");
|
||||
return strpos($var, '.') === false ? $var . '.0' : $var;
|
||||
|
||||
} elseif (is_bool($var)) {
|
||||
return $var ? 'true' : 'false';
|
||||
|
||||
} elseif ($var === null) {
|
||||
return 'null';
|
||||
|
||||
} elseif (is_string($var) && (preg_match('#[^\x09\x20-\x7E\xA0-\x{10FFFF}]#u', $var) || preg_last_error())) {
|
||||
static $table;
|
||||
if ($table === null) {
|
||||
foreach (array_merge(range("\x00", "\x1F"), range("\x7F", "\xFF")) as $ch) {
|
||||
$table[$ch] = '\x' . str_pad(dechex(ord($ch)), 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$table['\\'] = '\\\\';
|
||||
$table["\r"] = '\r';
|
||||
$table["\n"] = '\n';
|
||||
$table["\t"] = '\t';
|
||||
$table['$'] = '\$';
|
||||
$table['"'] = '\"';
|
||||
}
|
||||
return '"' . strtr($var, $table) . '"';
|
||||
|
||||
} elseif (is_array($var)) {
|
||||
$space = str_repeat("\t", $level);
|
||||
|
||||
static $marker;
|
||||
if ($marker === null) {
|
||||
$marker = uniqid("\x00", true);
|
||||
}
|
||||
if (empty($var)) {
|
||||
$out = '';
|
||||
|
||||
} elseif ($level > self::$maxDepth || isset($var[$marker])) {
|
||||
return '/* Nesting level too deep or recursive dependency */';
|
||||
|
||||
} else {
|
||||
$out = "\n$space";
|
||||
$outShort = '';
|
||||
$var[$marker] = true;
|
||||
$oldLine = $line;
|
||||
$line++;
|
||||
$counter = 0;
|
||||
foreach ($var as $k => &$v) {
|
||||
if ($k !== $marker) {
|
||||
$item = ($k === $counter ? '' : self::_toPhp($k, $list, $level + 1, $line) . ' => ') . self::_toPhp($v, $list, $level + 1, $line);
|
||||
$counter = is_int($k) ? max($k + 1, $counter) : $counter;
|
||||
$outShort .= ($outShort === '' ? '' : ', ') . $item;
|
||||
$out .= "\t$item,\n$space";
|
||||
$line++;
|
||||
}
|
||||
}
|
||||
unset($var[$marker]);
|
||||
if (strpos($outShort, "\n") === false && strlen($outShort) < self::$maxLength) {
|
||||
$line = $oldLine;
|
||||
$out = $outShort;
|
||||
}
|
||||
}
|
||||
return '[' . $out . ']';
|
||||
|
||||
} elseif ($var instanceof \Closure) {
|
||||
$rc = new \ReflectionFunction($var);
|
||||
return "/* Closure defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */";
|
||||
|
||||
} elseif (is_object($var)) {
|
||||
if (($rc = new \ReflectionObject($var))->isAnonymous()) {
|
||||
return "/* Anonymous class defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */";
|
||||
}
|
||||
$arr = (array) $var;
|
||||
$space = str_repeat("\t", $level);
|
||||
$class = get_class($var);
|
||||
$used = &$list[spl_object_hash($var)];
|
||||
|
||||
if (empty($arr)) {
|
||||
$out = '';
|
||||
|
||||
} elseif ($used) {
|
||||
return "/* $class dumped on line $used */";
|
||||
|
||||
} elseif ($level > self::$maxDepth) {
|
||||
return '/* Nesting level too deep */';
|
||||
|
||||
} else {
|
||||
$out = "\n";
|
||||
$used = $line;
|
||||
$line++;
|
||||
foreach ($arr as $k => &$v) {
|
||||
if (isset($k[0]) && $k[0] === "\x00") {
|
||||
$k = substr($k, strrpos($k, "\x00") + 1);
|
||||
}
|
||||
$out .= "$space\t" . self::_toPhp($k, $list, $level + 1, $line) . ' => ' . self::_toPhp($v, $list, $level + 1, $line) . ",\n";
|
||||
$line++;
|
||||
}
|
||||
$out .= $space;
|
||||
}
|
||||
$hash = self::hash($var);
|
||||
return $class === 'stdClass'
|
||||
? "(object) /* $hash */ [$out]"
|
||||
: "$class::__set_state(/* $hash */ [$out])";
|
||||
|
||||
} elseif (is_resource($var)) {
|
||||
return '/* resource ' . get_resource_type($var) . ' */';
|
||||
|
||||
} else {
|
||||
$res = var_export($var, true);
|
||||
$line += substr_count($res, "\n");
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function dumpException(\Throwable $e): string
|
||||
{
|
||||
$trace = $e->getTrace();
|
||||
array_splice($trace, 0, $e instanceof \ErrorException ? 1 : 0, [['file' => $e->getFile(), 'line' => $e->getLine()]]);
|
||||
|
||||
$testFile = null;
|
||||
foreach (array_reverse($trace) as $item) {
|
||||
if (isset($item['file'])) { // in case of shutdown handler, we want to skip inner-code blocks and debugging calls
|
||||
$testFile = $item['file'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($e instanceof AssertException) {
|
||||
$expected = $e->expected;
|
||||
$actual = $e->actual;
|
||||
|
||||
if (is_object($expected) || is_array($expected) || (is_string($expected) && strlen($expected) > self::$maxLength)
|
||||
|| is_object($actual) || is_array($actual) || (is_string($actual) && strlen($actual) > self::$maxLength)
|
||||
) {
|
||||
$args = isset($_SERVER['argv'][1])
|
||||
? '.[' . implode(' ', preg_replace(['#^-*(.{1,20}).*#i', '#[^=a-z0-9. -]+#i'], ['$1', '-'], array_slice($_SERVER['argv'], 1))) . ']'
|
||||
: '';
|
||||
$stored[] = self::saveOutput($testFile, $expected, $args . '.expected');
|
||||
$stored[] = self::saveOutput($testFile, $actual, $args . '.actual');
|
||||
}
|
||||
|
||||
if ((is_string($actual) && is_string($expected))) {
|
||||
for ($i = 0; $i < strlen($actual) && isset($expected[$i]) && $actual[$i] === $expected[$i]; $i++);
|
||||
for (; $i && $i < strlen($actual) && $actual[$i - 1] >= "\x80" && $actual[$i] >= "\x80" && $actual[$i] < "\xC0"; $i--);
|
||||
$i = max(0, min(
|
||||
$i - (int) (self::$maxLength / 3), // try to display 1/3 of shorter string
|
||||
max(strlen($actual), strlen($expected)) - self::$maxLength + 3 // 3 = length of ...
|
||||
));
|
||||
if ($i) {
|
||||
$expected = substr_replace($expected, '...', 0, $i);
|
||||
$actual = substr_replace($actual, '...', 0, $i);
|
||||
}
|
||||
}
|
||||
|
||||
$message = 'Failed: ' . $e->origMessage;
|
||||
if (((is_string($actual) && is_string($expected)) || (is_array($actual) && is_array($expected)))
|
||||
&& preg_match('#^(.*)(%\d)(.*)(%\d.*)\z#s', $message, $m)
|
||||
) {
|
||||
if (($delta = strlen($m[1]) - strlen($m[3])) >= 3) {
|
||||
$message = "$m[1]$m[2]\n" . str_repeat(' ', $delta - 3) . "...$m[3]$m[4]";
|
||||
} else {
|
||||
$message = "$m[1]$m[2]$m[3]\n" . str_repeat(' ', strlen($m[1]) - 4) . "... $m[4]";
|
||||
}
|
||||
}
|
||||
$message = strtr($message, [
|
||||
'%1' => self::color('yellow') . self::toLine($actual) . self::color('white'),
|
||||
'%2' => self::color('yellow') . self::toLine($expected) . self::color('white'),
|
||||
]);
|
||||
} else {
|
||||
$message = ($e instanceof \ErrorException ? Helpers::errorTypeToString($e->getSeverity()) : get_class($e))
|
||||
. ': ' . preg_replace('#[\x00-\x09\x0B-\x1F]+#', ' ', $e->getMessage());
|
||||
}
|
||||
|
||||
$s = self::color('white', $message) . "\n\n"
|
||||
. (isset($stored) ? 'diff ' . Helpers::escapeArg($stored[0]) . ' ' . Helpers::escapeArg($stored[1]) . "\n\n" : '');
|
||||
|
||||
foreach ($trace as $item) {
|
||||
$item += ['file' => null, 'class' => null, 'type' => null, 'function' => null];
|
||||
if ($e instanceof AssertException && $item['file'] === __DIR__ . DIRECTORY_SEPARATOR . 'Assert.php') {
|
||||
continue;
|
||||
}
|
||||
$line = $item['class'] === Assert::class && method_exists($item['class'], $item['function'])
|
||||
&& strpos($tmp = file($item['file'])[$item['line'] - 1], "::$item[function](") ? $tmp : null;
|
||||
|
||||
$s .= 'in '
|
||||
. ($item['file']
|
||||
? (
|
||||
($item['file'] === $testFile ? self::color('white') : '')
|
||||
. implode(DIRECTORY_SEPARATOR, array_slice(explode(DIRECTORY_SEPARATOR, $item['file']), -self::$maxPathSegments))
|
||||
. "($item[line])" . self::color('gray') . ' '
|
||||
)
|
||||
: '[internal function]'
|
||||
)
|
||||
. ($line
|
||||
? trim($line)
|
||||
: $item['class'] . $item['type'] . $item['function'] . ($item['function'] ? '()' : '')
|
||||
)
|
||||
. self::color() . "\n";
|
||||
}
|
||||
|
||||
if ($e->getPrevious()) {
|
||||
$s .= "\n(previous) " . static::dumpException($e->getPrevious());
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Dumps data to folder 'output'.
|
||||
* @internal
|
||||
*/
|
||||
public static function saveOutput(string $testFile, $content, string $suffix = ''): string
|
||||
{
|
||||
$path = self::$dumpDir . DIRECTORY_SEPARATOR . pathinfo($testFile, PATHINFO_FILENAME) . $suffix;
|
||||
if (!preg_match('#/|\w:#A', self::$dumpDir)) {
|
||||
$path = dirname($testFile) . DIRECTORY_SEPARATOR . $path;
|
||||
}
|
||||
@mkdir(dirname($path)); // @ - directory may already exist
|
||||
file_put_contents($path, is_string($content) ? $content : (self::toPhp($content) . "\n"));
|
||||
return $path;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Applies color to string.
|
||||
*/
|
||||
public static function color(string $color = '', string $s = null): string
|
||||
{
|
||||
static $colors = [
|
||||
'black' => '0;30', 'gray' => '1;30', 'silver' => '0;37', 'white' => '1;37',
|
||||
'navy' => '0;34', 'blue' => '1;34', 'green' => '0;32', 'lime' => '1;32',
|
||||
'teal' => '0;36', 'aqua' => '1;36', 'maroon' => '0;31', 'red' => '1;31',
|
||||
'purple' => '0;35', 'fuchsia' => '1;35', 'olive' => '0;33', 'yellow' => '1;33',
|
||||
null => '0',
|
||||
];
|
||||
$c = explode('/', $color);
|
||||
return "\e["
|
||||
. str_replace(';', "m\e[", $colors[$c[0]] . (empty($c[1]) ? '' : ';4' . substr($colors[$c[1]], -1)))
|
||||
. 'm' . $s . ($s === null ? '' : "\e[0m");
|
||||
}
|
||||
|
||||
|
||||
public static function removeColors(string $s): string
|
||||
{
|
||||
return preg_replace('#\e\[[\d;]+m#', '', $s);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* Testing environment.
|
||||
*/
|
||||
class Environment
|
||||
{
|
||||
/** Should Test use console colors? */
|
||||
public const COLORS = 'NETTE_TESTER_COLORS';
|
||||
|
||||
/** Test is run by Runner */
|
||||
public const RUNNER = 'NETTE_TESTER_RUNNER';
|
||||
|
||||
/** Code coverage engine */
|
||||
public const COVERAGE_ENGINE = 'NETTE_TESTER_COVERAGE_ENGINE';
|
||||
|
||||
/** Code coverage file */
|
||||
public const COVERAGE = 'NETTE_TESTER_COVERAGE';
|
||||
|
||||
/** Thread number when run tests in multi threads */
|
||||
public const THREAD = 'NETTE_TESTER_THREAD';
|
||||
|
||||
/** @var bool */
|
||||
public static $checkAssertions = false;
|
||||
|
||||
/** @var bool */
|
||||
public static $useColors;
|
||||
|
||||
/** @var int initial output buffer level */
|
||||
private static $obLevel;
|
||||
|
||||
|
||||
/**
|
||||
* Configures testing environment.
|
||||
*/
|
||||
public static function setup(): void
|
||||
{
|
||||
self::setupErrors();
|
||||
self::setupColors();
|
||||
self::$obLevel = ob_get_level();
|
||||
|
||||
class_exists(Runner\Job::class);
|
||||
class_exists(Dumper::class);
|
||||
class_exists(Assert::class);
|
||||
|
||||
$annotations = self::getTestAnnotations();
|
||||
self::$checkAssertions = !isset($annotations['outputmatch']) && !isset($annotations['outputmatchfile']);
|
||||
|
||||
if (getenv(self::COVERAGE) && getenv(self::COVERAGE_ENGINE)) {
|
||||
CodeCoverage\Collector::start(getenv(self::COVERAGE), getenv(self::COVERAGE_ENGINE));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Configures colored output.
|
||||
*/
|
||||
public static function setupColors(): void
|
||||
{
|
||||
self::$useColors = getenv(self::COLORS) !== false
|
||||
? (bool) getenv(self::COLORS)
|
||||
: ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg')
|
||||
&& ((function_exists('posix_isatty') && posix_isatty(STDOUT))
|
||||
|| getenv('ConEmuANSI') === 'ON' || getenv('ANSICON') !== false) || getenv('TERM') === 'xterm-256color');
|
||||
|
||||
ob_start(function (string $s): string {
|
||||
return self::$useColors ? $s : Dumper::removeColors($s);
|
||||
}, 1, PHP_OUTPUT_HANDLER_FLUSHABLE);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Configures PHP error handling.
|
||||
*/
|
||||
public static function setupErrors(): void
|
||||
{
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('html_errors', '0');
|
||||
ini_set('log_errors', '0');
|
||||
|
||||
set_exception_handler([__CLASS__, 'handleException']);
|
||||
|
||||
set_error_handler(function (int $severity, string $message, string $file, int $line): ?bool {
|
||||
if (in_array($severity, [E_RECOVERABLE_ERROR, E_USER_ERROR], true) || ($severity & error_reporting()) === $severity) {
|
||||
self::handleException(new \ErrorException($message, 0, $severity, $file, $line));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
register_shutdown_function(function (): void {
|
||||
Assert::$onFailure = [__CLASS__, 'handleException'];
|
||||
|
||||
$error = error_get_last();
|
||||
register_shutdown_function(function () use ($error): void {
|
||||
if (in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) {
|
||||
if (($error['type'] & error_reporting()) !== $error['type']) { // show fatal errors hidden by @shutup
|
||||
self::removeOutputBuffers();
|
||||
echo "\nFatal error: $error[message] in $error[file] on line $error[line]\n";
|
||||
}
|
||||
} elseif (self::$checkAssertions && !Assert::$counter) {
|
||||
self::removeOutputBuffers();
|
||||
echo "\nError: This test forgets to execute an assertion.\n";
|
||||
exit(Runner\Job::CODE_FAIL);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function handleException(\Throwable $e): void
|
||||
{
|
||||
self::removeOutputBuffers();
|
||||
self::$checkAssertions = false;
|
||||
echo Dumper::dumpException($e);
|
||||
exit($e instanceof AssertException ? Runner\Job::CODE_FAIL : Runner\Job::CODE_ERROR);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Skips this test.
|
||||
*/
|
||||
public static function skip(string $message = ''): void
|
||||
{
|
||||
self::$checkAssertions = false;
|
||||
echo "\nSkipped:\n$message\n";
|
||||
die(Runner\Job::CODE_SKIP);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Locks the parallel tests.
|
||||
* @param string $path lock store directory
|
||||
*/
|
||||
public static function lock(string $name = '', string $path = ''): void
|
||||
{
|
||||
static $locks;
|
||||
$file = "$path/lock-" . md5($name);
|
||||
if (!isset($locks[$file])) {
|
||||
flock($locks[$file] = fopen($file, 'w'), LOCK_EX);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns current test annotations.
|
||||
*/
|
||||
public static function getTestAnnotations(): array
|
||||
{
|
||||
$trace = debug_backtrace();
|
||||
$file = $trace[count($trace) - 1]['file'];
|
||||
return Helpers::parseDocComment(file_get_contents($file)) + ['file' => $file];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Removes keyword final from source codes.
|
||||
*/
|
||||
public static function bypassFinals(): void
|
||||
{
|
||||
FileMutator::addMutator(function (string $code): string {
|
||||
if (strpos($code, 'final') !== false) {
|
||||
$tokens = token_get_all($code, TOKEN_PARSE);
|
||||
$code = '';
|
||||
foreach ($tokens as $token) {
|
||||
$code .= is_array($token)
|
||||
? ($token[0] === T_FINAL ? '' : $token[1])
|
||||
: $token;
|
||||
}
|
||||
}
|
||||
return $code;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Loads data according to the file annotation or specified by Tester\Runner\TestHandler::initiateDataProvider()
|
||||
*/
|
||||
public static function loadData(): array
|
||||
{
|
||||
if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--dataprovider=(.*)#Ai', '$1', $_SERVER['argv']))) {
|
||||
[$query, $file] = explode('|', reset($tmp), 2);
|
||||
|
||||
} else {
|
||||
$annotations = self::getTestAnnotations();
|
||||
if (!isset($annotations['dataprovider'])) {
|
||||
throw new \Exception('Missing annotation @dataProvider.');
|
||||
}
|
||||
$provider = (array) $annotations['dataprovider'];
|
||||
[$file, $query] = DataProvider::parseAnnotation($provider[0], $annotations['file']);
|
||||
}
|
||||
$data = DataProvider::load($file, $query);
|
||||
return reset($data);
|
||||
}
|
||||
|
||||
|
||||
private static function removeOutputBuffers(): void
|
||||
{
|
||||
while (ob_get_level() > self::$obLevel && @ob_end_flush()); // @ may be not removable
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* Expectations for more complex assertions formulation.
|
||||
*
|
||||
* @method static self same($expected)
|
||||
* @method static self notSame($expected)
|
||||
* @method static self equal($expected)
|
||||
* @method static self notEqual($expected)
|
||||
* @method static self contains($needle)
|
||||
* @method static self notContains($needle)
|
||||
* @method static self true()
|
||||
* @method static self false()
|
||||
* @method static self null()
|
||||
* @method static self nan()
|
||||
* @method static self truthy()
|
||||
* @method static self falsey()
|
||||
* @method static self count(int $count)
|
||||
* @method static self type(string|object $type)
|
||||
* @method static self match(string $pattern)
|
||||
* @method static self matchFile(string $file)
|
||||
*
|
||||
* @method self andSame($expected)
|
||||
* @method self andNotSame($expected)
|
||||
* @method self andEqual($expected)
|
||||
* @method self andNotEqual($expected)
|
||||
* @method self andContains($needle)
|
||||
* @method self andNotContains($needle)
|
||||
* @method self andTrue()
|
||||
* @method self andFalse()
|
||||
* @method self andNull()
|
||||
* @method self andNan()
|
||||
* @method self andTruthy()
|
||||
* @method self andFalsey()
|
||||
* @method self andCount(int $count)
|
||||
* @method self andType(string|object $type)
|
||||
* @method self andMatch(string $pattern)
|
||||
* @method self andMatchFile(string $file)
|
||||
*/
|
||||
class Expect
|
||||
{
|
||||
/** @var array of self|\Closure|\stdClass */
|
||||
private $constraints = [];
|
||||
|
||||
|
||||
public static function __callStatic(string $method, array $args): self
|
||||
{
|
||||
$me = new self;
|
||||
$me->constraints[] = (object) ['method' => $method, 'args' => $args];
|
||||
return $me;
|
||||
}
|
||||
|
||||
|
||||
public static function that(callable $constraint): self
|
||||
{
|
||||
return (new self)->and($constraint);
|
||||
}
|
||||
|
||||
|
||||
public function __call(string $method, array $args): self
|
||||
{
|
||||
if (preg_match('#^and([A-Z]\w+)#', $method, $m)) {
|
||||
$this->constraints[] = (object) ['method' => lcfirst($m[1]), 'args' => $args];
|
||||
return $this;
|
||||
}
|
||||
throw new \Error('Call to undefined method ' . __CLASS__ . '::' . $method . '()');
|
||||
}
|
||||
|
||||
|
||||
public function and(callable $constraint): self
|
||||
{
|
||||
$this->constraints[] = $constraint;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks the expectations.
|
||||
*/
|
||||
public function __invoke($actual): void
|
||||
{
|
||||
foreach ($this->constraints as $cstr) {
|
||||
if ($cstr instanceof \stdClass) {
|
||||
$args = $cstr->args;
|
||||
$args[] = $actual;
|
||||
Assert::{$cstr->method}(...$args);
|
||||
|
||||
} elseif ($cstr($actual) === false) {
|
||||
Assert::fail('%1 is expected to be %2', $actual, is_string($cstr) ? $cstr : 'user-expectation');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function dump(): string
|
||||
{
|
||||
$res = [];
|
||||
foreach ($this->constraints as $cstr) {
|
||||
if ($cstr instanceof \stdClass) {
|
||||
$args = isset($cstr->args[0]) ? Dumper::toLine($cstr->args[0]) : '';
|
||||
$res[] = "$cstr->method($args)";
|
||||
|
||||
} elseif ($cstr instanceof self) {
|
||||
$res[] = $cstr->dump();
|
||||
|
||||
} else {
|
||||
$res[] = is_string($cstr) ? $cstr : 'user-expectation';
|
||||
}
|
||||
}
|
||||
return implode(',', $res);
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* Mock files.
|
||||
*/
|
||||
class FileMock
|
||||
{
|
||||
private const PROTOCOL = 'mock';
|
||||
|
||||
/** @var string[] */
|
||||
public static $files = [];
|
||||
|
||||
/** @var string */
|
||||
private $content;
|
||||
|
||||
/** @var int */
|
||||
private $readingPos;
|
||||
|
||||
/** @var int */
|
||||
private $writingPos;
|
||||
|
||||
/** @var bool */
|
||||
private $appendMode;
|
||||
|
||||
/** @var bool */
|
||||
private $isReadable;
|
||||
|
||||
/** @var bool */
|
||||
private $isWritable;
|
||||
|
||||
|
||||
/**
|
||||
* @return string file name
|
||||
*/
|
||||
public static function create(string $content = '', string $extension = null): string
|
||||
{
|
||||
self::register();
|
||||
|
||||
static $id;
|
||||
$name = self::PROTOCOL . '://' . (++$id) . '.' . $extension;
|
||||
self::$files[$name] = $content;
|
||||
return $name;
|
||||
}
|
||||
|
||||
|
||||
public static function register(): void
|
||||
{
|
||||
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
|
||||
stream_wrapper_register(self::PROTOCOL, __CLASS__);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function stream_open(string $path, string $mode): bool
|
||||
{
|
||||
if (!preg_match('#^([rwaxc]).*?(\+)?#', $mode, $m)) {
|
||||
// Windows: failed to open stream: Bad file descriptor
|
||||
// Linux: failed to open stream: Illegal seek
|
||||
$this->warning("failed to open stream: Invalid mode '$mode'");
|
||||
return false;
|
||||
|
||||
} elseif ($m[1] === 'x' && isset(self::$files[$path])) {
|
||||
$this->warning('failed to open stream: File exists');
|
||||
return false;
|
||||
|
||||
} elseif ($m[1] === 'r' && !isset(self::$files[$path])) {
|
||||
$this->warning('failed to open stream: No such file or directory');
|
||||
return false;
|
||||
|
||||
} elseif ($m[1] === 'w' || $m[1] === 'x') {
|
||||
self::$files[$path] = '';
|
||||
}
|
||||
|
||||
$this->content = &self::$files[$path];
|
||||
$this->content = (string) $this->content;
|
||||
$this->appendMode = $m[1] === 'a';
|
||||
$this->readingPos = 0;
|
||||
$this->writingPos = $this->appendMode ? strlen($this->content) : 0;
|
||||
$this->isReadable = isset($m[2]) || $m[1] === 'r';
|
||||
$this->isWritable = isset($m[2]) || $m[1] !== 'r';
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function stream_read(int $length): string
|
||||
{
|
||||
if (!$this->isReadable) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$result = substr($this->content, $this->readingPos, $length);
|
||||
$this->readingPos += strlen($result);
|
||||
$this->writingPos += $this->appendMode ? 0 : strlen($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
public function stream_write(string $data): int
|
||||
{
|
||||
if (!$this->isWritable) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$length = strlen($data);
|
||||
$this->content = str_pad($this->content, $this->writingPos, "\x00");
|
||||
$this->content = substr_replace($this->content, $data, $this->writingPos, $length);
|
||||
$this->readingPos += $length;
|
||||
$this->writingPos += $length;
|
||||
return $length;
|
||||
}
|
||||
|
||||
|
||||
public function stream_tell(): int
|
||||
{
|
||||
return $this->readingPos;
|
||||
}
|
||||
|
||||
|
||||
public function stream_eof(): bool
|
||||
{
|
||||
return $this->readingPos >= strlen($this->content);
|
||||
}
|
||||
|
||||
|
||||
public function stream_seek(int $offset, int $whence): bool
|
||||
{
|
||||
if ($whence === SEEK_CUR) {
|
||||
$offset += $this->readingPos;
|
||||
} elseif ($whence === SEEK_END) {
|
||||
$offset += strlen($this->content);
|
||||
}
|
||||
if ($offset >= 0) {
|
||||
$this->readingPos = $offset;
|
||||
$this->writingPos = $this->appendMode ? $this->writingPos : $offset;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function stream_truncate(int $size): bool
|
||||
{
|
||||
if (!$this->isWritable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->content = substr(str_pad($this->content, $size, "\x00"), 0, $size);
|
||||
$this->writingPos = $this->appendMode ? $size : $this->writingPos;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public function stream_stat(): array
|
||||
{
|
||||
return ['mode' => 0100666, 'size' => strlen($this->content)];
|
||||
}
|
||||
|
||||
|
||||
public function url_stat(string $path, int $flags)
|
||||
{
|
||||
return isset(self::$files[$path])
|
||||
? ['mode' => 0100666, 'size' => strlen(self::$files[$path])]
|
||||
: false;
|
||||
}
|
||||
|
||||
|
||||
public function stream_lock(int $operation): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public function unlink(string $path): bool
|
||||
{
|
||||
if (isset(self::$files[$path])) {
|
||||
unset(self::$files[$path]);
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->warning('No such file');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
private function warning(string $message): void
|
||||
{
|
||||
$bt = debug_backtrace(0, 3);
|
||||
if (isset($bt[2]['function'])) {
|
||||
$message = $bt[2]['function'] . '(' . @$bt[2]['args'][0] . '): ' . $message;
|
||||
}
|
||||
|
||||
trigger_error($message, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* PHP file mutator.
|
||||
*/
|
||||
class FileMutator
|
||||
{
|
||||
private const PROTOCOL = 'file';
|
||||
|
||||
/** @var resource|null */
|
||||
public $context;
|
||||
|
||||
/** @var resource|null */
|
||||
private $handle;
|
||||
|
||||
/** @var callable[] */
|
||||
private static $mutators = [];
|
||||
|
||||
|
||||
public static function addMutator(callable $mutator): void
|
||||
{
|
||||
self::$mutators[] = $mutator;
|
||||
stream_wrapper_unregister(self::PROTOCOL);
|
||||
stream_wrapper_register(self::PROTOCOL, __CLASS__);
|
||||
}
|
||||
|
||||
|
||||
public function dir_closedir(): void
|
||||
{
|
||||
closedir($this->handle);
|
||||
}
|
||||
|
||||
|
||||
public function dir_opendir(string $path, int $options): bool
|
||||
{
|
||||
$this->handle = $this->context
|
||||
? $this->native('opendir', $path, $this->context)
|
||||
: $this->native('opendir', $path);
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
|
||||
public function dir_readdir()
|
||||
{
|
||||
return readdir($this->handle);
|
||||
}
|
||||
|
||||
|
||||
public function dir_rewinddir(): bool
|
||||
{
|
||||
return (bool) rewinddir($this->handle);
|
||||
}
|
||||
|
||||
|
||||
public function mkdir(string $path, int $mode, int $options): bool
|
||||
{
|
||||
$recursive = (bool) ($options & STREAM_MKDIR_RECURSIVE);
|
||||
return $this->native('mkdir', $path, $mode, $recursive, $this->context);
|
||||
}
|
||||
|
||||
|
||||
public function rename(string $pathFrom, string $pathTo): bool
|
||||
{
|
||||
return $this->native('rename', $pathFrom, $pathTo, $this->context);
|
||||
}
|
||||
|
||||
|
||||
public function rmdir(string $path, int $options): bool
|
||||
{
|
||||
return $this->native('rmdir', $path, $this->context);
|
||||
}
|
||||
|
||||
|
||||
public function stream_cast(int $castAs)
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
|
||||
public function stream_close(): void
|
||||
{
|
||||
fclose($this->handle);
|
||||
}
|
||||
|
||||
|
||||
public function stream_eof(): bool
|
||||
{
|
||||
return feof($this->handle);
|
||||
}
|
||||
|
||||
|
||||
public function stream_flush(): bool
|
||||
{
|
||||
return fflush($this->handle);
|
||||
}
|
||||
|
||||
|
||||
public function stream_lock(int $operation): bool
|
||||
{
|
||||
return flock($this->handle, $operation);
|
||||
}
|
||||
|
||||
|
||||
public function stream_metadata(string $path, int $option, $value): bool
|
||||
{
|
||||
switch ($option) {
|
||||
case STREAM_META_TOUCH:
|
||||
$value += [null, null];
|
||||
return $this->native('touch', $path, $value[0], $value[1]);
|
||||
case STREAM_META_OWNER_NAME:
|
||||
case STREAM_META_OWNER:
|
||||
return $this->native('chown', $path, $value);
|
||||
case STREAM_META_GROUP_NAME:
|
||||
case STREAM_META_GROUP:
|
||||
return $this->native('chgrp', $path, $value);
|
||||
case STREAM_META_ACCESS:
|
||||
return $this->native('chmod', $path, $value);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
|
||||
{
|
||||
$usePath = (bool) ($options & STREAM_USE_PATH);
|
||||
if (pathinfo($path, PATHINFO_EXTENSION) === 'php') {
|
||||
$content = $this->native('file_get_contents', $path, $usePath, $this->context);
|
||||
if ($content === false) {
|
||||
return false;
|
||||
} else {
|
||||
foreach (self::$mutators as $mutator) {
|
||||
$content = $mutator($content);
|
||||
}
|
||||
$this->handle = tmpfile();
|
||||
$this->native('fwrite', $this->handle, $content);
|
||||
$this->native('fseek', $this->handle, 0);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
$this->handle = $this->context
|
||||
? $this->native('fopen', $path, $mode, $usePath, $this->context)
|
||||
: $this->native('fopen', $path, $mode, $usePath);
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function stream_read(int $count)
|
||||
{
|
||||
return fread($this->handle, $count);
|
||||
}
|
||||
|
||||
|
||||
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
|
||||
{
|
||||
return fseek($this->handle, $offset, $whence) === 0;
|
||||
}
|
||||
|
||||
|
||||
public function stream_set_option(int $option, int $arg1, int $arg2)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return fstat($this->handle);
|
||||
}
|
||||
|
||||
|
||||
public function stream_tell(): int
|
||||
{
|
||||
return ftell($this->handle);
|
||||
}
|
||||
|
||||
|
||||
public function stream_truncate(int $newSize): bool
|
||||
{
|
||||
return ftruncate($this->handle, $newSize);
|
||||
}
|
||||
|
||||
|
||||
public function stream_write(string $data): int
|
||||
{
|
||||
return fwrite($this->handle, $data);
|
||||
}
|
||||
|
||||
|
||||
public function unlink(string $path): bool
|
||||
{
|
||||
return $this->native('unlink', $path);
|
||||
}
|
||||
|
||||
|
||||
public function url_stat(string $path, int $flags)
|
||||
{
|
||||
$func = $flags & STREAM_URL_STAT_LINK ? 'lstat' : 'stat';
|
||||
return $flags & STREAM_URL_STAT_QUIET
|
||||
? @$this->native($func, $path)
|
||||
: $this->native($func, $path);
|
||||
}
|
||||
|
||||
|
||||
private function native(string $func)
|
||||
{
|
||||
stream_wrapper_restore(self::PROTOCOL);
|
||||
$res = $func(...array_slice(func_get_args(), 1));
|
||||
stream_wrapper_unregister(self::PROTOCOL);
|
||||
stream_wrapper_register(self::PROTOCOL, __CLASS__);
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* Test helpers.
|
||||
*/
|
||||
class Helpers
|
||||
{
|
||||
/**
|
||||
* Purges directory.
|
||||
*/
|
||||
public static function purge(string $dir): void
|
||||
{
|
||||
if (preg_match('#^(\w:)?[/\\\\]?$#', $dir)) {
|
||||
throw new \InvalidArgumentException('Directory must not be an empty string or root path.');
|
||||
}
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir);
|
||||
}
|
||||
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST) as $entry) {
|
||||
if ($entry->isDir()) {
|
||||
rmdir((string) $entry);
|
||||
} else {
|
||||
unlink((string) $entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Find common directory for given paths. All files or directories must exist.
|
||||
* @return string Empty when not found. Slash and back slash chars normalized to DIRECTORY_SEPARATOR.
|
||||
*/
|
||||
public static function findCommonDirectory(array $paths): string
|
||||
{
|
||||
$splitPaths = array_map(function ($s) {
|
||||
$real = realpath($s);
|
||||
if ($s === '') {
|
||||
throw new \RuntimeException('Path must not be empty.');
|
||||
} elseif ($real === false) {
|
||||
throw new \RuntimeException("File or directory '$s' does not exist.");
|
||||
}
|
||||
return explode(DIRECTORY_SEPARATOR, $real);
|
||||
}, $paths);
|
||||
|
||||
$first = (array) array_shift($splitPaths);
|
||||
for ($i = 0; $i < count($first); $i++) {
|
||||
foreach ($splitPaths as $s) {
|
||||
if ($first[$i] !== ($s[$i] ?? null)) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
$common = implode(DIRECTORY_SEPARATOR, array_slice($first, 0, $i));
|
||||
return is_dir($common) ? $common : dirname($common);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse phpDoc comment.
|
||||
* @internal
|
||||
*/
|
||||
public static function parseDocComment(string $s): array
|
||||
{
|
||||
$options = [];
|
||||
if (!preg_match('#^/\*\*(.*?)\*/#ms', $s, $content)) {
|
||||
return [];
|
||||
}
|
||||
if (preg_match('#^[ \t\*]*+([^\s@].*)#mi', $content[1], $matches)) {
|
||||
$options[0] = trim($matches[1]);
|
||||
}
|
||||
preg_match_all('#^[ \t\*]*@(\w+)([^\w\r\n].*)?#mi', $content[1], $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $match) {
|
||||
$ref = &$options[strtolower($match[1])];
|
||||
if (isset($ref)) {
|
||||
$ref = (array) $ref;
|
||||
$ref = &$ref[];
|
||||
}
|
||||
$ref = isset($match[2]) ? trim($match[2]) : '';
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public static function errorTypeToString(int $type): string
|
||||
{
|
||||
$consts = get_defined_constants(true);
|
||||
foreach ($consts['Core'] as $name => $val) {
|
||||
if ($type === $val && substr($name, 0, 2) === 'E_') {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Escape a string to be used as a shell argument.
|
||||
*/
|
||||
public static function escapeArg(string $s): string
|
||||
{
|
||||
if (preg_match('#^[a-z0-9._=/:-]+\z#i', $s)) {
|
||||
return $s;
|
||||
}
|
||||
|
||||
return defined('PHP_WINDOWS_VERSION_BUILD')
|
||||
? '"' . str_replace('"', '""', $s) . '"'
|
||||
: escapeshellarg($s);
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester;
|
||||
|
||||
|
||||
/**
|
||||
* Single test case.
|
||||
*/
|
||||
class TestCase
|
||||
{
|
||||
/** @internal */
|
||||
public const
|
||||
LIST_METHODS = 'nette-tester-list-methods',
|
||||
METHOD_PATTERN = '#^test[A-Z0-9_]#';
|
||||
|
||||
/** @var bool */
|
||||
private $handleErrors = false;
|
||||
|
||||
/** @var callable|null|false */
|
||||
private $prevErrorHandler = false;
|
||||
|
||||
|
||||
/**
|
||||
* Runs the test case.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
if (func_num_args()) {
|
||||
throw new \LogicException('Calling TestCase::run($method) is deprecated. Use TestCase::runTest($method) instead.');
|
||||
}
|
||||
|
||||
$methods = array_values(preg_grep(self::METHOD_PATTERN, array_map(function (\ReflectionMethod $rm): string {
|
||||
return $rm->getName();
|
||||
}, (new \ReflectionObject($this))->getMethods())));
|
||||
|
||||
if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--method=([\w-]+)$#Ai', '$1', $_SERVER['argv']))) {
|
||||
$method = reset($tmp);
|
||||
if ($method === self::LIST_METHODS) {
|
||||
Environment::$checkAssertions = false;
|
||||
header('Content-Type: text/plain');
|
||||
echo '[' . implode(',', $methods) . ']';
|
||||
return;
|
||||
}
|
||||
$this->runTest($method);
|
||||
|
||||
} else {
|
||||
foreach ($methods as $method) {
|
||||
$this->runTest($method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Runs the test method.
|
||||
* @param array $args test method parameters (dataprovider bypass)
|
||||
*/
|
||||
public function runTest(string $method, array $args = null): void
|
||||
{
|
||||
if (!method_exists($this, $method)) {
|
||||
throw new TestCaseException("Method '$method' does not exist.");
|
||||
} elseif (!preg_match(self::METHOD_PATTERN, $method)) {
|
||||
throw new TestCaseException("Method '$method' is not a testing method.");
|
||||
}
|
||||
|
||||
$method = new \ReflectionMethod($this, $method);
|
||||
if (!$method->isPublic()) {
|
||||
throw new TestCaseException("Method {$method->getName()} is not public. Make it public or rename it.");
|
||||
}
|
||||
|
||||
$info = Helpers::parseDocComment((string) $method->getDocComment()) + ['dataprovider' => null, 'throws' => null];
|
||||
|
||||
if ($info['throws'] === '') {
|
||||
throw new TestCaseException("Missing class name in @throws annotation for {$method->getName()}().");
|
||||
} elseif (is_array($info['throws'])) {
|
||||
throw new TestCaseException("Annotation @throws for {$method->getName()}() can be specified only once.");
|
||||
} else {
|
||||
$throws = is_string($info['throws']) ? preg_split('#\s+#', $info['throws'], 2) : [];
|
||||
}
|
||||
|
||||
$data = [];
|
||||
if ($args === null) {
|
||||
$defaultParams = [];
|
||||
foreach ($method->getParameters() as $param) {
|
||||
$defaultParams[$param->getName()] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null;
|
||||
}
|
||||
|
||||
foreach ((array) $info['dataprovider'] as $provider) {
|
||||
$res = $this->getData($provider);
|
||||
if (!is_array($res) && !$res instanceof \Traversable) {
|
||||
throw new TestCaseException("Data provider $provider() doesn't return array or Traversable.");
|
||||
}
|
||||
foreach ($res as $set) {
|
||||
$data[] = is_string(key($set)) ? array_merge($defaultParams, $set) : $set;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$info['dataprovider']) {
|
||||
if ($method->getNumberOfRequiredParameters()) {
|
||||
throw new TestCaseException("Method {$method->getName()}() has arguments, but @dataProvider is missing.");
|
||||
}
|
||||
$data[] = [];
|
||||
}
|
||||
} else {
|
||||
$data[] = $args;
|
||||
}
|
||||
|
||||
|
||||
if ($this->prevErrorHandler === false) {
|
||||
$this->prevErrorHandler = set_error_handler(function (int $severity): ?bool {
|
||||
if ($this->handleErrors && ($severity & error_reporting()) === $severity) {
|
||||
$this->handleErrors = false;
|
||||
$this->silentTearDown();
|
||||
}
|
||||
|
||||
return $this->prevErrorHandler ? ($this->prevErrorHandler)(...func_get_args()) : false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
foreach ($data as $params) {
|
||||
try {
|
||||
$this->setUp();
|
||||
|
||||
$this->handleErrors = true;
|
||||
$params = array_values($params);
|
||||
try {
|
||||
if ($info['throws']) {
|
||||
$e = Assert::error(function () use ($method, $params): void {
|
||||
[$this, $method->getName()](...$params);
|
||||
}, ...$throws);
|
||||
if ($e instanceof AssertException) {
|
||||
throw $e;
|
||||
}
|
||||
} else {
|
||||
[$this, $method->getName()](...$params);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->handleErrors = false;
|
||||
$this->silentTearDown();
|
||||
throw $e;
|
||||
}
|
||||
$this->handleErrors = false;
|
||||
|
||||
$this->tearDown();
|
||||
|
||||
} catch (AssertException $e) {
|
||||
throw $e->setMessage("$e->origMessage in {$method->getName()}(" . (substr(Dumper::toLine($params), 1, -1)) . ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
protected function getData(string $provider)
|
||||
{
|
||||
if (strpos($provider, '.') === false) {
|
||||
return $this->$provider();
|
||||
} else {
|
||||
$rc = new \ReflectionClass($this);
|
||||
[$file, $query] = DataProvider::parseAnnotation($provider, $rc->getFileName());
|
||||
return DataProvider::load($file, $query);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This method is called before a test is executed.
|
||||
* @return void
|
||||
*/
|
||||
protected function setUp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This method is called after a test is executed.
|
||||
* @return void
|
||||
*/
|
||||
protected function tearDown()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
private function silentTearDown(): void
|
||||
{
|
||||
set_error_handler(function () {});
|
||||
try {
|
||||
$this->tearDown();
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
restore_error_handler();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestCaseException extends \Exception
|
||||
{
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner;
|
||||
|
||||
use Tester\CodeCoverage;
|
||||
use Tester\Dumper;
|
||||
use Tester\Environment;
|
||||
use Tester\Helpers;
|
||||
|
||||
|
||||
/**
|
||||
* CLI Tester.
|
||||
*/
|
||||
class CliTester
|
||||
{
|
||||
/** @var array */
|
||||
private $options;
|
||||
|
||||
/** @var PhpInterpreter */
|
||||
private $interpreter;
|
||||
|
||||
/** @var bool */
|
||||
private $debugMode = true;
|
||||
|
||||
|
||||
public function run(): ?int
|
||||
{
|
||||
Environment::setupColors();
|
||||
$this->setupErrors();
|
||||
|
||||
ob_start();
|
||||
$cmd = $this->loadOptions();
|
||||
|
||||
$this->debugMode = (bool) $this->options['--debug'];
|
||||
if (isset($this->options['--colors'])) {
|
||||
Environment::$useColors = (bool) $this->options['--colors'];
|
||||
} elseif (in_array($this->options['-o'], ['tap', 'junit'], true)) {
|
||||
Environment::$useColors = false;
|
||||
}
|
||||
|
||||
if ($cmd->isEmpty() || $this->options['--help']) {
|
||||
$cmd->help();
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->createPhpInterpreter();
|
||||
|
||||
if ($this->options['--info']) {
|
||||
$job = new Job(new Test(__DIR__ . '/info.php'), $this->interpreter);
|
||||
$job->run();
|
||||
echo $job->getTest()->stdout;
|
||||
return null;
|
||||
}
|
||||
|
||||
$runner = $this->createRunner();
|
||||
$runner->setEnvironmentVariable(Environment::RUNNER, '1');
|
||||
$runner->setEnvironmentVariable(Environment::COLORS, (string) (int) Environment::$useColors);
|
||||
|
||||
if ($this->options['--coverage']) {
|
||||
$coverageFile = $this->prepareCodeCoverage($runner);
|
||||
}
|
||||
|
||||
if ($this->options['-o'] !== null) {
|
||||
ob_clean();
|
||||
}
|
||||
ob_end_flush();
|
||||
|
||||
if ($this->options['--watch']) {
|
||||
$this->watch($runner);
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $runner->run();
|
||||
|
||||
if (isset($coverageFile) && preg_match('#\.(?:html?|xml)\z#', $coverageFile)) {
|
||||
$this->finishCodeCoverage($coverageFile);
|
||||
}
|
||||
|
||||
return $result ? 0 : 1;
|
||||
}
|
||||
|
||||
|
||||
private function loadOptions(): CommandLine
|
||||
{
|
||||
echo <<<'XX'
|
||||
_____ ___ ___ _____ ___ ___
|
||||
|_ _/ __)( __/_ _/ __)| _ )
|
||||
|_| \___ /___) |_| \___ |_|_\ v2.2.0
|
||||
|
||||
|
||||
XX;
|
||||
|
||||
$cmd = new CommandLine(<<<'XX'
|
||||
Usage:
|
||||
tester.php [options] [<test file> | <directory>]...
|
||||
|
||||
Options:
|
||||
-p <path> Specify PHP interpreter to run (default: php).
|
||||
-c <path> Look for php.ini file (or look in directory) <path>.
|
||||
-C Use system-wide php.ini.
|
||||
-l | --log <path> Write log to file <path>.
|
||||
-d <key=value>... Define INI entry 'key' with value 'value'.
|
||||
-s Show information about skipped tests.
|
||||
--stop-on-fail Stop execution upon the first failure.
|
||||
-j <num> Run <num> jobs in parallel (default: 8).
|
||||
-o <console|tap|junit|none> Specify output format.
|
||||
-w | --watch <path> Watch directory.
|
||||
-i | --info Show tests environment info and exit.
|
||||
--setup <path> Script for runner setup.
|
||||
--temp <path> Path to temporary directory. Default by sys_get_temp_dir().
|
||||
--colors [1|0] Enable or disable colors.
|
||||
--coverage <path> Generate code coverage report to file.
|
||||
--coverage-src <path> Path to source code.
|
||||
-h | --help This help.
|
||||
|
||||
XX
|
||||
, [
|
||||
'-c' => [CommandLine::REALPATH => true],
|
||||
'--watch' => [CommandLine::REPEATABLE => true, CommandLine::REALPATH => true],
|
||||
'--setup' => [CommandLine::REALPATH => true],
|
||||
'--temp' => [CommandLine::REALPATH => true],
|
||||
'paths' => [CommandLine::REPEATABLE => true, CommandLine::VALUE => getcwd()],
|
||||
'--debug' => [],
|
||||
'--cider' => [],
|
||||
'--coverage-src' => [CommandLine::REALPATH => true, CommandLine::REPEATABLE => true],
|
||||
]);
|
||||
|
||||
if (isset($_SERVER['argv'])) {
|
||||
if ($tmp = array_search('-log', $_SERVER['argv'], true)) {
|
||||
$_SERVER['argv'][$tmp] = '--log';
|
||||
}
|
||||
|
||||
if ($tmp = array_search('--tap', $_SERVER['argv'], true)) {
|
||||
unset($_SERVER['argv'][$tmp]);
|
||||
$_SERVER['argv'] = array_merge($_SERVER['argv'], ['-o', 'tap']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->options = $cmd->parse();
|
||||
if ($this->options['--temp'] === null) {
|
||||
if (($temp = sys_get_temp_dir()) === '') {
|
||||
echo "Note: System temporary directory is not set.\n";
|
||||
} elseif (($real = realpath($temp)) === false) {
|
||||
echo "Note: System temporary directory '$temp' does not exist.\n";
|
||||
} else {
|
||||
$this->options['--temp'] = rtrim($real, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
}
|
||||
|
||||
return $cmd;
|
||||
}
|
||||
|
||||
|
||||
private function createPhpInterpreter(): void
|
||||
{
|
||||
$args = $this->options['-C'] ? [] : ['-n'];
|
||||
if ($this->options['-c']) {
|
||||
array_push($args, '-c', $this->options['-c']);
|
||||
} elseif (!$this->options['--info'] && !$this->options['-C']) {
|
||||
echo "Note: No php.ini is used.\n";
|
||||
}
|
||||
|
||||
if (in_array($this->options['-o'], ['tap', 'junit'], true)) {
|
||||
array_push($args, '-d', 'html_errors=off');
|
||||
}
|
||||
|
||||
foreach ($this->options['-d'] as $item) {
|
||||
array_push($args, '-d', $item);
|
||||
}
|
||||
|
||||
$this->interpreter = new PhpInterpreter($this->options['-p'], $args);
|
||||
|
||||
if ($error = $this->interpreter->getStartupError()) {
|
||||
echo Dumper::color('red', "PHP startup error: $error") . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function createRunner(): Runner
|
||||
{
|
||||
$runner = new Runner($this->interpreter);
|
||||
$runner->paths = $this->options['paths'];
|
||||
$runner->threadCount = max(1, (int) $this->options['-j']);
|
||||
$runner->stopOnFail = $this->options['--stop-on-fail'];
|
||||
|
||||
if ($this->options['--temp'] !== null) {
|
||||
$runner->setTempDirectory($this->options['--temp']);
|
||||
}
|
||||
|
||||
switch ($this->options['-o']) {
|
||||
case 'none':
|
||||
break;
|
||||
case 'tap':
|
||||
$runner->outputHandlers[] = new Output\TapPrinter;
|
||||
break;
|
||||
case 'junit':
|
||||
$runner->outputHandlers[] = new Output\JUnitPrinter;
|
||||
break;
|
||||
default:
|
||||
$runner->outputHandlers[] = new Output\ConsolePrinter(
|
||||
$runner,
|
||||
(bool) $this->options['-s'],
|
||||
'php://output',
|
||||
(bool) $this->options['--cider']
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->options['--log']) {
|
||||
echo "Log: {$this->options['--log']}\n";
|
||||
$runner->outputHandlers[] = new Output\Logger($runner, $this->options['--log']);
|
||||
}
|
||||
|
||||
if ($this->options['--setup']) {
|
||||
(function () use ($runner): void {
|
||||
require func_get_arg(0);
|
||||
})($this->options['--setup']);
|
||||
}
|
||||
return $runner;
|
||||
}
|
||||
|
||||
|
||||
private function prepareCodeCoverage(Runner $runner): string
|
||||
{
|
||||
$engines = $this->interpreter->getCodeCoverageEngines();
|
||||
if (count($engines) < 1) {
|
||||
throw new \Exception("Code coverage functionality requires Xdebug or PCOV extension or PHPDBG SAPI (used {$this->interpreter->getCommandLine()})");
|
||||
}
|
||||
|
||||
file_put_contents($this->options['--coverage'], '');
|
||||
$file = realpath($this->options['--coverage']);
|
||||
|
||||
$runner->setEnvironmentVariable(Environment::COVERAGE, $file);
|
||||
$runner->setEnvironmentVariable(Environment::COVERAGE_ENGINE, $engine = reset($engines));
|
||||
|
||||
if ($engine === CodeCoverage\Collector::ENGINE_PCOV && count($this->options['--coverage-src'])) {
|
||||
$runner->addPhpIniOption('pcov.directory', Helpers::findCommonDirectory($this->options['--coverage-src']));
|
||||
}
|
||||
|
||||
echo "Code coverage by $engine: $file\n";
|
||||
return $file;
|
||||
}
|
||||
|
||||
|
||||
private function finishCodeCoverage(string $file): void
|
||||
{
|
||||
if (!in_array($this->options['-o'], ['none', 'tap', 'junit'], true)) {
|
||||
echo 'Generating code coverage report... ';
|
||||
}
|
||||
if (filesize($file) === 0) {
|
||||
echo 'failed. Coverage file is empty. Do you call Tester\Environment::setup() in tests?';
|
||||
return;
|
||||
}
|
||||
if (pathinfo($file, PATHINFO_EXTENSION) === 'xml') {
|
||||
$generator = new CodeCoverage\Generators\CloverXMLGenerator($file, $this->options['--coverage-src']);
|
||||
} else {
|
||||
$generator = new CodeCoverage\Generators\HtmlGenerator($file, $this->options['--coverage-src']);
|
||||
}
|
||||
$generator->render($file);
|
||||
echo round($generator->getCoveredPercent()) . "% covered\n";
|
||||
}
|
||||
|
||||
|
||||
private function watch(Runner $runner): void
|
||||
{
|
||||
$prev = [];
|
||||
$counter = 0;
|
||||
while (true) {
|
||||
$state = [];
|
||||
foreach ($this->options['--watch'] as $directory) {
|
||||
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) as $file) {
|
||||
if (substr($file->getExtension(), 0, 3) === 'php' && substr($file->getBasename(), 0, 1) !== '.') {
|
||||
$state[(string) $file] = @md5_file((string) $file); // @ file could be deleted in the meantime
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($state !== $prev) {
|
||||
$prev = $state;
|
||||
try {
|
||||
$runner->run();
|
||||
} catch (\ErrorException $e) {
|
||||
$this->displayException($e);
|
||||
}
|
||||
echo "\n";
|
||||
$time = time();
|
||||
}
|
||||
|
||||
$idle = time() - $time;
|
||||
if ($idle >= 60 * 60) {
|
||||
$idle = 'long time';
|
||||
} elseif ($idle >= 60) {
|
||||
$idle = round($idle / 60) . ' min';
|
||||
} else {
|
||||
$idle = $idle . ' sec';
|
||||
}
|
||||
echo 'Watching ' . implode(', ', $this->options['--watch']) . " (idle for $idle) " . str_repeat('.', ++$counter % 5) . " \r";
|
||||
sleep(2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function setupErrors(): void
|
||||
{
|
||||
error_reporting(E_ALL);
|
||||
ini_set('html_errors', '0');
|
||||
|
||||
set_error_handler(function (int $severity, string $message, string $file, int $line) {
|
||||
if (($severity & error_reporting()) === $severity) {
|
||||
throw new \ErrorException($message, 0, $severity, $file, $line);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
set_exception_handler(function (\Throwable $e) {
|
||||
$this->displayException($e);
|
||||
exit(2);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private function displayException(\Throwable $e): void
|
||||
{
|
||||
echo "\n";
|
||||
echo $this->debugMode
|
||||
? Dumper::dumpException($e)
|
||||
: Dumper::color('white/red', 'Error: ' . $e->getMessage());
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner;
|
||||
|
||||
|
||||
/**
|
||||
* Stupid command line arguments parser.
|
||||
*/
|
||||
class CommandLine
|
||||
{
|
||||
public const
|
||||
ARGUMENT = 'argument',
|
||||
OPTIONAL = 'optional',
|
||||
REPEATABLE = 'repeatable',
|
||||
ENUM = 'enum',
|
||||
REALPATH = 'realpath',
|
||||
VALUE = 'default';
|
||||
|
||||
/** @var array[] */
|
||||
private $options = [];
|
||||
|
||||
/** @var string[] */
|
||||
private $aliases = [];
|
||||
|
||||
/** @var bool[] */
|
||||
private $positional = [];
|
||||
|
||||
/** @var string */
|
||||
private $help;
|
||||
|
||||
|
||||
public function __construct(string $help, array $defaults = [])
|
||||
{
|
||||
$this->help = $help;
|
||||
$this->options = $defaults;
|
||||
|
||||
preg_match_all('#^[ \t]+(--?\w.*?)(?: .*\(default: (.*)\)| |\r|$)#m', $help, $lines, PREG_SET_ORDER);
|
||||
foreach ($lines as $line) {
|
||||
preg_match_all('#(--?\w[\w-]*)(?:[= ](<.*?>|\[.*?]|\w+)(\.{0,3}))?[ ,|]*#A', $line[1], $m);
|
||||
if (!count($m[0]) || count($m[0]) > 2 || implode('', $m[0]) !== $line[1]) {
|
||||
throw new \InvalidArgumentException("Unable to parse '$line[1]'.");
|
||||
}
|
||||
|
||||
$name = end($m[1]);
|
||||
$opts = $this->options[$name] ?? [];
|
||||
$this->options[$name] = $opts + [
|
||||
self::ARGUMENT => (bool) end($m[2]),
|
||||
self::OPTIONAL => isset($line[2]) || (substr(end($m[2]), 0, 1) === '[') || isset($opts[self::VALUE]),
|
||||
self::REPEATABLE => (bool) end($m[3]),
|
||||
self::ENUM => count($enums = explode('|', trim(end($m[2]), '<[]>'))) > 1 ? $enums : null,
|
||||
self::VALUE => $line[2] ?? null,
|
||||
];
|
||||
if ($name !== $m[1][0]) {
|
||||
$this->aliases[$m[1][0]] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->options as $name => $foo) {
|
||||
if ($name[0] !== '-') {
|
||||
$this->positional[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function parse(array $args = null): array
|
||||
{
|
||||
if ($args === null) {
|
||||
$args = isset($_SERVER['argv']) ? array_slice($_SERVER['argv'], 1) : [];
|
||||
}
|
||||
$params = [];
|
||||
reset($this->positional);
|
||||
$i = 0;
|
||||
while ($i < count($args)) {
|
||||
$arg = $args[$i++];
|
||||
if ($arg[0] !== '-') {
|
||||
if (!current($this->positional)) {
|
||||
throw new \Exception("Unexpected parameter $arg.");
|
||||
}
|
||||
$name = current($this->positional);
|
||||
$this->checkArg($this->options[$name], $arg);
|
||||
if (empty($this->options[$name][self::REPEATABLE])) {
|
||||
$params[$name] = $arg;
|
||||
next($this->positional);
|
||||
} else {
|
||||
$params[$name][] = $arg;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $arg] = strpos($arg, '=') ? explode('=', $arg, 2) : [$arg, true];
|
||||
|
||||
if (isset($this->aliases[$name])) {
|
||||
$name = $this->aliases[$name];
|
||||
|
||||
} elseif (!isset($this->options[$name])) {
|
||||
throw new \Exception("Unknown option $name.");
|
||||
}
|
||||
|
||||
$opt = $this->options[$name];
|
||||
|
||||
if ($arg !== true && empty($opt[self::ARGUMENT])) {
|
||||
throw new \Exception("Option $name has not argument.");
|
||||
|
||||
} elseif ($arg === true && !empty($opt[self::ARGUMENT])) {
|
||||
if (isset($args[$i]) && $args[$i][0] !== '-') {
|
||||
$arg = $args[$i++];
|
||||
} elseif (empty($opt[self::OPTIONAL])) {
|
||||
throw new \Exception("Option $name requires argument.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($opt[self::ENUM]) && !in_array($arg, $opt[self::ENUM], true) && !($opt[self::OPTIONAL] && $arg === true)) {
|
||||
throw new \Exception("Value of option $name must be " . implode(', or ', $opt[self::ENUM]) . '.');
|
||||
}
|
||||
$this->checkArg($opt, $arg);
|
||||
|
||||
if (empty($opt[self::REPEATABLE])) {
|
||||
$params[$name] = $arg;
|
||||
} else {
|
||||
$params[$name][] = $arg;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->options as $name => $opt) {
|
||||
if (isset($params[$name])) {
|
||||
continue;
|
||||
} elseif (isset($opt[self::VALUE])) {
|
||||
$params[$name] = $opt[self::VALUE];
|
||||
} elseif ($name[0] !== '-' && empty($opt[self::OPTIONAL])) {
|
||||
throw new \Exception("Missing required argument <$name>.");
|
||||
} else {
|
||||
$params[$name] = null;
|
||||
}
|
||||
if (!empty($opt[self::REPEATABLE])) {
|
||||
$params[$name] = (array) $params[$name];
|
||||
}
|
||||
}
|
||||
return $params;
|
||||
}
|
||||
|
||||
|
||||
public function help(): void
|
||||
{
|
||||
echo $this->help;
|
||||
}
|
||||
|
||||
|
||||
public function checkArg(array $opt, &$arg): void
|
||||
{
|
||||
if (!empty($opt[self::REALPATH])) {
|
||||
$path = realpath($arg);
|
||||
if ($path === false) {
|
||||
throw new \Exception("File path '$arg' not found.");
|
||||
}
|
||||
$arg = $path;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return !isset($_SERVER['argv']) || count($_SERVER['argv']) < 2;
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner;
|
||||
|
||||
use Tester\Helpers;
|
||||
|
||||
|
||||
/**
|
||||
* Single test job.
|
||||
*/
|
||||
class Job
|
||||
{
|
||||
public const
|
||||
CODE_NONE = -1,
|
||||
CODE_OK = 0,
|
||||
CODE_SKIP = 177,
|
||||
CODE_FAIL = 178,
|
||||
CODE_ERROR = 255;
|
||||
|
||||
/** waiting time between process activity check in microseconds */
|
||||
public const RUN_USLEEP = 10000;
|
||||
|
||||
public const
|
||||
RUN_ASYNC = 1,
|
||||
RUN_COLLECT_ERRORS = 2;
|
||||
|
||||
/** @var Test */
|
||||
private $test;
|
||||
|
||||
/** @var PhpInterpreter */
|
||||
private $interpreter;
|
||||
|
||||
/** @var string[] environment variables for test */
|
||||
private $envVars;
|
||||
|
||||
/** @var resource|null */
|
||||
private $proc;
|
||||
|
||||
/** @var resource|null */
|
||||
private $stdout;
|
||||
|
||||
/** @var resource|null */
|
||||
private $stderr;
|
||||
|
||||
/** @var int */
|
||||
private $exitCode = self::CODE_NONE;
|
||||
|
||||
/** @var string[] output headers */
|
||||
private $headers = [];
|
||||
|
||||
|
||||
public function __construct(Test $test, PhpInterpreter $interpreter, array $envVars = null)
|
||||
{
|
||||
if ($test->getResult() !== Test::PREPARED) {
|
||||
throw new \LogicException("Test '{$test->getSignature()}' already has result '{$test->getResult()}'.");
|
||||
}
|
||||
|
||||
$test->stdout = '';
|
||||
$test->stderr = '';
|
||||
|
||||
$this->test = $test;
|
||||
$this->interpreter = $interpreter;
|
||||
$this->envVars = (array) $envVars;
|
||||
}
|
||||
|
||||
|
||||
public function setEnvironmentVariable(string $name, string $value): void
|
||||
{
|
||||
$this->envVars[$name] = $value;
|
||||
}
|
||||
|
||||
|
||||
public function getEnvironmentVariable(string $name): string
|
||||
{
|
||||
return $this->envVars[$name];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Runs single test.
|
||||
* @param int $flags self::RUN_ASYNC | self::RUN_COLLECT_ERRORS
|
||||
*/
|
||||
public function run(int $flags = 0): void
|
||||
{
|
||||
foreach ($this->envVars as $name => $value) {
|
||||
putenv("$name=$value");
|
||||
}
|
||||
|
||||
$args = [];
|
||||
foreach ($this->test->getArguments() as $value) {
|
||||
if (is_array($value)) {
|
||||
$args[] = Helpers::escapeArg("--$value[0]=$value[1]");
|
||||
} else {
|
||||
$args[] = Helpers::escapeArg($value);
|
||||
}
|
||||
}
|
||||
|
||||
$this->proc = proc_open(
|
||||
$this->interpreter->getCommandLine()
|
||||
. ' -d register_argc_argv=on ' . Helpers::escapeArg($this->test->getFile()) . ' ' . implode(' ', $args),
|
||||
[
|
||||
['pipe', 'r'],
|
||||
['pipe', 'w'],
|
||||
['pipe', 'w'],
|
||||
],
|
||||
$pipes,
|
||||
dirname($this->test->getFile()),
|
||||
null,
|
||||
['bypass_shell' => true]
|
||||
);
|
||||
|
||||
foreach (array_keys($this->envVars) as $name) {
|
||||
putenv($name);
|
||||
}
|
||||
|
||||
[$stdin, $this->stdout, $stderr] = $pipes;
|
||||
fclose($stdin);
|
||||
if ($flags & self::RUN_COLLECT_ERRORS) {
|
||||
$this->stderr = $stderr;
|
||||
} else {
|
||||
fclose($stderr);
|
||||
}
|
||||
|
||||
if ($flags & self::RUN_ASYNC) {
|
||||
stream_set_blocking($this->stdout, false); // on Windows does not work with proc_open()
|
||||
if ($this->stderr) {
|
||||
stream_set_blocking($this->stderr, false);
|
||||
}
|
||||
} else {
|
||||
while ($this->isRunning()) {
|
||||
usleep(self::RUN_USLEEP); // stream_select() doesn't work with proc_open()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if the test is still running.
|
||||
*/
|
||||
public function isRunning(): bool
|
||||
{
|
||||
if (!is_resource($this->stdout)) {
|
||||
return false;
|
||||
}
|
||||
$this->test->stdout .= stream_get_contents($this->stdout);
|
||||
if ($this->stderr) {
|
||||
$this->test->stderr .= stream_get_contents($this->stderr);
|
||||
}
|
||||
|
||||
$status = proc_get_status($this->proc);
|
||||
if ($status['running']) {
|
||||
return true;
|
||||
}
|
||||
|
||||
fclose($this->stdout);
|
||||
if ($this->stderr) {
|
||||
fclose($this->stderr);
|
||||
}
|
||||
$code = proc_close($this->proc);
|
||||
$this->exitCode = $code === self::CODE_NONE ? $status['exitcode'] : $code;
|
||||
|
||||
if ($this->interpreter->isCgi() && count($tmp = explode("\r\n\r\n", $this->test->stdout, 2)) >= 2) {
|
||||
[$headers, $this->test->stdout] = $tmp;
|
||||
foreach (explode("\r\n", $headers) as $header) {
|
||||
$pos = strpos($header, ':');
|
||||
if ($pos !== false) {
|
||||
$this->headers[trim(substr($header, 0, $pos))] = trim(substr($header, $pos + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
public function getTest(): Test
|
||||
{
|
||||
return $this->test;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns exit code.
|
||||
*/
|
||||
public function getExitCode(): int
|
||||
{
|
||||
return $this->exitCode;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns output headers.
|
||||
* @return string[]
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner\Output;
|
||||
|
||||
use Tester;
|
||||
use Tester\Dumper;
|
||||
use Tester\Runner\Runner;
|
||||
use Tester\Runner\Test;
|
||||
|
||||
|
||||
/**
|
||||
* Console printer.
|
||||
*/
|
||||
class ConsolePrinter implements Tester\Runner\OutputHandler
|
||||
{
|
||||
/** @var Runner */
|
||||
private $runner;
|
||||
|
||||
/** @var bool display skipped tests information? */
|
||||
private $displaySkipped = false;
|
||||
|
||||
/** @var resource */
|
||||
private $file;
|
||||
|
||||
/** @var string */
|
||||
private $buffer;
|
||||
|
||||
/** @var float */
|
||||
private $time;
|
||||
|
||||
/** @var int */
|
||||
private $count;
|
||||
|
||||
/** @var array */
|
||||
private $results;
|
||||
|
||||
/** @var string */
|
||||
private $baseDir;
|
||||
|
||||
/** @var array */
|
||||
private $symbols;
|
||||
|
||||
|
||||
public function __construct(Runner $runner, bool $displaySkipped = false, string $file = 'php://output', bool $ciderMode = false)
|
||||
{
|
||||
$this->runner = $runner;
|
||||
$this->displaySkipped = $displaySkipped;
|
||||
$this->file = fopen($file, 'w');
|
||||
$this->symbols = [
|
||||
Test::PASSED => $ciderMode ? Dumper::color('green', '🍎') : '.',
|
||||
Test::SKIPPED => 's',
|
||||
Test::FAILED => $ciderMode ? Dumper::color('red', '🍎') : Dumper::color('white/red', 'F'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public function begin(): void
|
||||
{
|
||||
$this->count = 0;
|
||||
$this->baseDir = null;
|
||||
$this->results = [
|
||||
Test::PASSED => 0,
|
||||
Test::SKIPPED => 0,
|
||||
Test::FAILED => 0,
|
||||
];
|
||||
$this->time = -microtime(true);
|
||||
fwrite($this->file, $this->runner->getInterpreter()->getShortInfo()
|
||||
. ' | ' . $this->runner->getInterpreter()->getCommandLine()
|
||||
. " | {$this->runner->threadCount} thread" . ($this->runner->threadCount > 1 ? 's' : '') . "\n\n");
|
||||
}
|
||||
|
||||
|
||||
public function prepare(Test $test): void
|
||||
{
|
||||
if ($this->baseDir === null) {
|
||||
$this->baseDir = dirname($test->getFile()) . DIRECTORY_SEPARATOR;
|
||||
} elseif (strpos($test->getFile(), $this->baseDir) !== 0) {
|
||||
$common = array_intersect_assoc(
|
||||
explode(DIRECTORY_SEPARATOR, $this->baseDir),
|
||||
explode(DIRECTORY_SEPARATOR, $test->getFile())
|
||||
);
|
||||
$this->baseDir = '';
|
||||
$prev = 0;
|
||||
foreach ($common as $i => $part) {
|
||||
if ($i !== $prev++) {
|
||||
break;
|
||||
}
|
||||
$this->baseDir .= $part . DIRECTORY_SEPARATOR;
|
||||
}
|
||||
}
|
||||
|
||||
$this->count++;
|
||||
}
|
||||
|
||||
|
||||
public function finish(Test $test): void
|
||||
{
|
||||
$this->results[$test->getResult()]++;
|
||||
fwrite($this->file, $this->symbols[$test->getResult()]);
|
||||
|
||||
$title = ($test->title ? "$test->title | " : '') . substr($test->getSignature(), strlen($this->baseDir));
|
||||
$message = ' ' . str_replace("\n", "\n ", trim((string) $test->message)) . "\n\n";
|
||||
if ($test->getResult() === Test::FAILED) {
|
||||
$this->buffer .= Dumper::color('red', "-- FAILED: $title") . "\n$message";
|
||||
} elseif ($test->getResult() === Test::SKIPPED && $this->displaySkipped) {
|
||||
$this->buffer .= "-- Skipped: $title\n$message";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function end(): void
|
||||
{
|
||||
$run = array_sum($this->results);
|
||||
fwrite($this->file, !$this->count ? "No tests found\n" :
|
||||
"\n\n" . $this->buffer . "\n"
|
||||
. ($this->results[Test::FAILED] ? Dumper::color('white/red') . 'FAILURES!' : Dumper::color('white/green') . 'OK')
|
||||
. " ($this->count test" . ($this->count > 1 ? 's' : '') . ', '
|
||||
. ($this->results[Test::FAILED] ? $this->results[Test::FAILED] . ' failure' . ($this->results[Test::FAILED] > 1 ? 's' : '') . ', ' : '')
|
||||
. ($this->results[Test::SKIPPED] ? $this->results[Test::SKIPPED] . ' skipped, ' : '')
|
||||
. ($this->count !== $run ? ($this->count - $run) . ' not run, ' : '')
|
||||
. sprintf('%0.1f', $this->time + microtime(true)) . ' seconds)' . Dumper::color() . "\n");
|
||||
|
||||
$this->buffer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner\Output;
|
||||
|
||||
use Tester;
|
||||
use Tester\Runner\Test;
|
||||
|
||||
|
||||
/**
|
||||
* JUnit xml format printer.
|
||||
*/
|
||||
class JUnitPrinter implements Tester\Runner\OutputHandler
|
||||
{
|
||||
/** @var resource */
|
||||
private $file;
|
||||
|
||||
/** @var string */
|
||||
private $buffer;
|
||||
|
||||
/** @var float */
|
||||
private $startTime;
|
||||
|
||||
/** @var array */
|
||||
private $results;
|
||||
|
||||
|
||||
public function __construct(string $file = 'php://output')
|
||||
{
|
||||
$this->file = fopen($file, 'w');
|
||||
}
|
||||
|
||||
|
||||
public function begin(): void
|
||||
{
|
||||
$this->results = [
|
||||
Test::PASSED => 0,
|
||||
Test::SKIPPED => 0,
|
||||
Test::FAILED => 0,
|
||||
];
|
||||
$this->startTime = microtime(true);
|
||||
fwrite($this->file, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuites>\n");
|
||||
}
|
||||
|
||||
|
||||
public function prepare(Test $test): void
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
public function finish(Test $test): void
|
||||
{
|
||||
$this->results[$test->getResult()]++;
|
||||
$this->buffer .= "\t\t<testcase classname=\"" . htmlspecialchars($test->getSignature()) . '" name="' . htmlspecialchars($test->getSignature()) . '"';
|
||||
|
||||
switch ($test->getResult()) {
|
||||
case Test::FAILED:
|
||||
$this->buffer .= ">\n\t\t\t<failure message=\"" . htmlspecialchars($test->message) . "\"/>\n\t\t</testcase>\n";
|
||||
break;
|
||||
case Test::SKIPPED:
|
||||
$this->buffer .= ">\n\t\t\t<skipped/>\n\t\t</testcase>\n";
|
||||
break;
|
||||
case Test::PASSED:
|
||||
$this->buffer .= "/>\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function end(): void
|
||||
{
|
||||
$time = sprintf('%0.1f', microtime(true) - $this->startTime);
|
||||
$output = $this->buffer;
|
||||
$this->buffer = "\t<testsuite errors=\"{$this->results[Test::FAILED]}\" skipped=\"{$this->results[Test::SKIPPED]}\" tests=\"" . array_sum($this->results) . "\" time=\"$time\" timestamp=\"" . @date('Y-m-d\TH:i:s') . "\">\n";
|
||||
$this->buffer .= $output;
|
||||
$this->buffer .= "\t</testsuite>";
|
||||
|
||||
fwrite($this->file, $this->buffer . "\n</testsuites>\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner\Output;
|
||||
|
||||
use Tester;
|
||||
use Tester\Runner\Runner;
|
||||
use Tester\Runner\Test;
|
||||
|
||||
|
||||
/**
|
||||
* Verbose logger.
|
||||
*/
|
||||
class Logger implements Tester\Runner\OutputHandler
|
||||
{
|
||||
/** @var Runner */
|
||||
private $runner;
|
||||
|
||||
/** @var resource */
|
||||
private $file;
|
||||
|
||||
/** @var int */
|
||||
private $count;
|
||||
|
||||
/** @var array */
|
||||
private $results;
|
||||
|
||||
|
||||
public function __construct(Runner $runner, string $file = 'php://output')
|
||||
{
|
||||
$this->runner = $runner;
|
||||
$this->file = fopen($file, 'w');
|
||||
}
|
||||
|
||||
|
||||
public function begin(): void
|
||||
{
|
||||
$this->count = 0;
|
||||
$this->results = [
|
||||
Test::PASSED => 0,
|
||||
Test::SKIPPED => 0,
|
||||
Test::FAILED => 0,
|
||||
];
|
||||
fwrite($this->file, 'PHP ' . $this->runner->getInterpreter()->getVersion()
|
||||
. ' | ' . $this->runner->getInterpreter()->getCommandLine()
|
||||
. " | {$this->runner->threadCount} threads\n\n");
|
||||
}
|
||||
|
||||
|
||||
public function prepare(Test $test): void
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
|
||||
|
||||
public function finish(Test $test): void
|
||||
{
|
||||
$this->results[$test->getResult()]++;
|
||||
$message = ' ' . str_replace("\n", "\n ", Tester\Dumper::removeColors(trim((string) $test->message)));
|
||||
$outputs = [
|
||||
Test::PASSED => "-- OK: {$test->getSignature()}",
|
||||
Test::SKIPPED => "-- Skipped: {$test->getSignature()}\n$message",
|
||||
Test::FAILED => "-- FAILED: {$test->getSignature()}\n$message",
|
||||
];
|
||||
fwrite($this->file, $outputs[$test->getResult()] . "\n\n");
|
||||
}
|
||||
|
||||
|
||||
public function end(): void
|
||||
{
|
||||
$run = array_sum($this->results);
|
||||
fwrite($this->file,
|
||||
($this->results[Test::FAILED] ? 'FAILURES!' : 'OK')
|
||||
. " ($this->count tests"
|
||||
. ($this->results[Test::FAILED] ? ", {$this->results[Test::FAILED]} failures" : '')
|
||||
. ($this->results[Test::SKIPPED] ? ", {$this->results[Test::SKIPPED]} skipped" : '')
|
||||
. ($this->count !== $run ? ', ' . ($this->count - $run) . ' not run' : '')
|
||||
. ')'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner\Output;
|
||||
|
||||
use Tester;
|
||||
use Tester\Runner\Test;
|
||||
|
||||
|
||||
/**
|
||||
* Test Anything Protocol, http://testanything.org
|
||||
*/
|
||||
class TapPrinter implements Tester\Runner\OutputHandler
|
||||
{
|
||||
/** @var resource */
|
||||
private $file;
|
||||
|
||||
/** @var array */
|
||||
private $results;
|
||||
|
||||
|
||||
public function __construct(string $file = 'php://output')
|
||||
{
|
||||
$this->file = fopen($file, 'w');
|
||||
}
|
||||
|
||||
|
||||
public function begin(): void
|
||||
{
|
||||
$this->results = [
|
||||
Test::PASSED => 0,
|
||||
Test::SKIPPED => 0,
|
||||
Test::FAILED => 0,
|
||||
];
|
||||
fwrite($this->file, "TAP version 13\n");
|
||||
}
|
||||
|
||||
|
||||
public function prepare(Test $test): void
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
public function finish(Test $test): void
|
||||
{
|
||||
$this->results[$test->getResult()]++;
|
||||
$message = str_replace("\n", "\n# ", trim((string) $test->message));
|
||||
$outputs = [
|
||||
Test::PASSED => "ok {$test->getSignature()}",
|
||||
Test::SKIPPED => "ok {$test->getSignature()} #skip $message",
|
||||
Test::FAILED => "not ok {$test->getSignature()}\n# $message",
|
||||
];
|
||||
fwrite($this->file, $outputs[$test->getResult()] . "\n");
|
||||
}
|
||||
|
||||
|
||||
public function end(): void
|
||||
{
|
||||
fwrite($this->file, '1..' . array_sum($this->results));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner;
|
||||
|
||||
|
||||
/**
|
||||
* Runner output.
|
||||
*/
|
||||
interface OutputHandler
|
||||
{
|
||||
function begin(): void;
|
||||
|
||||
function prepare(Test $test): void;
|
||||
|
||||
function finish(Test $test): void;
|
||||
|
||||
function end(): void;
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner;
|
||||
|
||||
use Tester\Helpers;
|
||||
|
||||
|
||||
/**
|
||||
* PHP command-line executable.
|
||||
*/
|
||||
class PhpInterpreter
|
||||
{
|
||||
/** @var string */
|
||||
private $commandLine;
|
||||
|
||||
/** @var bool is CGI? */
|
||||
private $cgi;
|
||||
|
||||
/** @var \stdClass created by info.php */
|
||||
private $info;
|
||||
|
||||
/** @var string */
|
||||
private $error;
|
||||
|
||||
|
||||
public function __construct(string $path, array $args = [])
|
||||
{
|
||||
$this->commandLine = Helpers::escapeArg($path);
|
||||
$proc = @proc_open( // @ is escalated to exception
|
||||
$this->commandLine . ' --version',
|
||||
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
|
||||
$pipes,
|
||||
null,
|
||||
null,
|
||||
['bypass_shell' => true]
|
||||
);
|
||||
if ($proc === false) {
|
||||
throw new \Exception("Cannot run PHP interpreter $path. Use -p option.");
|
||||
}
|
||||
fclose($pipes[0]);
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
proc_close($proc);
|
||||
|
||||
$args = ' ' . implode(' ', array_map([Helpers::class, 'escapeArg'], $args));
|
||||
if (strpos($output, 'phpdbg') !== false) {
|
||||
$args = ' -qrrb -S cli' . $args;
|
||||
}
|
||||
$this->commandLine .= rtrim($args);
|
||||
|
||||
$proc = proc_open(
|
||||
$this->commandLine . ' ' . Helpers::escapeArg(__DIR__ . '/info.php') . ' serialized',
|
||||
[['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']],
|
||||
$pipes,
|
||||
null,
|
||||
null,
|
||||
['bypass_shell' => true]
|
||||
);
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
$this->error = trim(stream_get_contents($pipes[2]));
|
||||
if (proc_close($proc)) {
|
||||
throw new \Exception("Unable to run $path: " . preg_replace('#[\r\n ]+#', ' ', $this->error));
|
||||
}
|
||||
|
||||
$parts = explode("\r\n\r\n", $output, 2);
|
||||
$this->cgi = count($parts) === 2;
|
||||
$this->info = @unserialize(strstr($parts[$this->cgi], 'O:8:"stdClass"'));
|
||||
$this->error .= strstr($parts[$this->cgi], 'O:8:"stdClass"', true);
|
||||
if (!$this->info) {
|
||||
throw new \Exception("Unable to detect PHP version (output: $output).");
|
||||
|
||||
} elseif ($this->info->phpDbgVersion && version_compare($this->info->version, '7.0.0', '<')) {
|
||||
throw new \Exception('Unable to use phpdbg on PHP < 7.0.0.');
|
||||
|
||||
} elseif ($this->cgi && $this->error) {
|
||||
$this->error .= "\n(note that PHP CLI generates better error messages)";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return static
|
||||
*/
|
||||
public function withPhpIniOption(string $name, string $value = null): self
|
||||
{
|
||||
$me = clone $this;
|
||||
$me->commandLine .= ' -d ' . Helpers::escapeArg($name . ($value === null ? '' : "=$value"));
|
||||
return $me;
|
||||
}
|
||||
|
||||
|
||||
public function getCommandLine(): string
|
||||
{
|
||||
return $this->commandLine;
|
||||
}
|
||||
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return $this->info->version;
|
||||
}
|
||||
|
||||
|
||||
public function getCodeCoverageEngines(): array
|
||||
{
|
||||
return $this->info->codeCoverageEngines;
|
||||
}
|
||||
|
||||
|
||||
public function isCgi(): bool
|
||||
{
|
||||
return $this->cgi;
|
||||
}
|
||||
|
||||
|
||||
public function getStartupError(): string
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
|
||||
public function getShortInfo(): string
|
||||
{
|
||||
return "PHP {$this->info->version} ({$this->info->sapi})"
|
||||
. ($this->info->phpDbgVersion ? "; PHPDBG {$this->info->phpDbgVersion}" : '');
|
||||
}
|
||||
|
||||
|
||||
public function hasExtension(string $name): bool
|
||||
{
|
||||
return in_array(strtolower($name), array_map('strtolower', $this->info->extensions), true);
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner;
|
||||
|
||||
use Tester\Environment;
|
||||
|
||||
|
||||
/**
|
||||
* Test runner.
|
||||
*/
|
||||
class Runner
|
||||
{
|
||||
/** @var string[] paths to test files/directories */
|
||||
public $paths = [];
|
||||
|
||||
/** @var int run in parallel threads */
|
||||
public $threadCount = 1;
|
||||
|
||||
/** @var TestHandler */
|
||||
public $testHandler;
|
||||
|
||||
/** @var OutputHandler[] */
|
||||
public $outputHandlers = [];
|
||||
|
||||
/** @var bool */
|
||||
public $stopOnFail = false;
|
||||
|
||||
/** @var PhpInterpreter */
|
||||
private $interpreter;
|
||||
|
||||
/** @var array */
|
||||
private $envVars = [];
|
||||
|
||||
/** @var Job[] */
|
||||
private $jobs;
|
||||
|
||||
/** @var bool */
|
||||
private $interrupted = false;
|
||||
|
||||
/** @var string|null */
|
||||
private $tempDir;
|
||||
|
||||
/** @var bool */
|
||||
private $result;
|
||||
|
||||
/** @var array */
|
||||
private $lastResults = [];
|
||||
|
||||
|
||||
public function __construct(PhpInterpreter $interpreter)
|
||||
{
|
||||
$this->interpreter = $interpreter;
|
||||
$this->testHandler = new TestHandler($this);
|
||||
}
|
||||
|
||||
|
||||
public function setEnvironmentVariable(string $name, string $value): void
|
||||
{
|
||||
$this->envVars[$name] = $value;
|
||||
}
|
||||
|
||||
|
||||
public function getEnvironmentVariables(): array
|
||||
{
|
||||
return $this->envVars;
|
||||
}
|
||||
|
||||
|
||||
public function addPhpIniOption(string $name, string $value = null): void
|
||||
{
|
||||
$this->interpreter = $this->interpreter->withPhpIniOption($name, $value);
|
||||
}
|
||||
|
||||
|
||||
public function setTempDirectory(?string $path): void
|
||||
{
|
||||
if ($path !== null) {
|
||||
if (!is_dir($path) || !is_writable($path)) {
|
||||
throw new \RuntimeException("Path '$path' is not a writable directory.");
|
||||
}
|
||||
|
||||
$path = realpath($path) . DIRECTORY_SEPARATOR . 'Tester';
|
||||
if (!is_dir($path) && @mkdir($path) === false && !is_dir($path)) { // @ - directory may exist
|
||||
throw new \RuntimeException("Cannot create '$path' directory.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->tempDir = $path;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Runs all tests.
|
||||
*/
|
||||
public function run(): bool
|
||||
{
|
||||
$this->result = true;
|
||||
$this->interrupted = false;
|
||||
|
||||
foreach ($this->outputHandlers as $handler) {
|
||||
$handler->begin();
|
||||
}
|
||||
|
||||
$this->jobs = $running = [];
|
||||
foreach ($this->paths as $path) {
|
||||
$this->findTests($path);
|
||||
}
|
||||
|
||||
if ($this->tempDir) {
|
||||
usort($this->jobs, function (Job $a, Job $b): int {
|
||||
return $this->getLastResult($a->getTest()) - $this->getLastResult($b->getTest());
|
||||
});
|
||||
}
|
||||
|
||||
$threads = range(1, $this->threadCount);
|
||||
|
||||
$this->installInterruptHandler();
|
||||
while (($this->jobs || $running) && !$this->isInterrupted()) {
|
||||
while ($threads && $this->jobs) {
|
||||
$running[] = $job = array_shift($this->jobs);
|
||||
$async = $this->threadCount > 1 && (count($running) + count($this->jobs) > 1);
|
||||
$job->setEnvironmentVariable(Environment::THREAD, (string) array_shift($threads));
|
||||
$job->run($async ? $job::RUN_ASYNC : 0);
|
||||
}
|
||||
|
||||
if (count($running) > 1) {
|
||||
usleep(Job::RUN_USLEEP); // stream_select() doesn't work with proc_open()
|
||||
}
|
||||
|
||||
foreach ($running as $key => $job) {
|
||||
if ($this->isInterrupted()) {
|
||||
break 2;
|
||||
}
|
||||
|
||||
if (!$job->isRunning()) {
|
||||
$threads[] = $job->getEnvironmentVariable(Environment::THREAD);
|
||||
$this->testHandler->assess($job);
|
||||
unset($running[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->removeInterruptHandler();
|
||||
|
||||
foreach ($this->outputHandlers as $handler) {
|
||||
$handler->end();
|
||||
}
|
||||
|
||||
return $this->result;
|
||||
}
|
||||
|
||||
|
||||
private function findTests(string $path): void
|
||||
{
|
||||
if (strpbrk($path, '*?') === false && !file_exists($path)) {
|
||||
throw new \InvalidArgumentException("File or directory '$path' not found.");
|
||||
}
|
||||
|
||||
if (is_dir($path)) {
|
||||
foreach (glob(str_replace('[', '[[]', $path) . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
|
||||
$this->findTests($dir);
|
||||
}
|
||||
|
||||
$this->findTests($path . '/*.phpt');
|
||||
$this->findTests($path . '/*Test.php');
|
||||
|
||||
} else {
|
||||
foreach (glob(str_replace('[', '[[]', $path)) ?: [] as $file) {
|
||||
if (is_file($file)) {
|
||||
$this->testHandler->initiate(realpath($file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Appends new job to queue.
|
||||
*/
|
||||
public function addJob(Job $job): void
|
||||
{
|
||||
$this->jobs[] = $job;
|
||||
}
|
||||
|
||||
|
||||
public function prepareTest(Test $test): void
|
||||
{
|
||||
foreach ($this->outputHandlers as $handler) {
|
||||
$handler->prepare($test);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Writes to output handlers.
|
||||
*/
|
||||
public function finishTest(Test $test): void
|
||||
{
|
||||
$this->result = $this->result && ($test->getResult() !== Test::FAILED);
|
||||
|
||||
foreach ($this->outputHandlers as $handler) {
|
||||
$handler->finish($test);
|
||||
}
|
||||
|
||||
if ($this->tempDir) {
|
||||
$lastResult = &$this->lastResults[$test->getSignature()];
|
||||
if ($lastResult !== $test->getResult()) {
|
||||
file_put_contents($this->getLastResultFilename($test), $lastResult = $test->getResult());
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->stopOnFail && $test->getResult() === Test::FAILED) {
|
||||
$this->interrupted = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function getInterpreter(): PhpInterpreter
|
||||
{
|
||||
return $this->interpreter;
|
||||
}
|
||||
|
||||
|
||||
private function installInterruptHandler(): void
|
||||
{
|
||||
if (extension_loaded('pcntl')) {
|
||||
pcntl_signal(SIGINT, function (): void {
|
||||
pcntl_signal(SIGINT, SIG_DFL);
|
||||
$this->interrupted = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function removeInterruptHandler(): void
|
||||
{
|
||||
if (extension_loaded('pcntl')) {
|
||||
pcntl_signal(SIGINT, SIG_DFL);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function isInterrupted(): bool
|
||||
{
|
||||
if (extension_loaded('pcntl')) {
|
||||
pcntl_signal_dispatch();
|
||||
}
|
||||
|
||||
return $this->interrupted;
|
||||
}
|
||||
|
||||
|
||||
private function getLastResult(Test $test): int
|
||||
{
|
||||
$signature = $test->getSignature();
|
||||
if (isset($this->lastResults[$signature])) {
|
||||
return $this->lastResults[$signature];
|
||||
}
|
||||
|
||||
$file = $this->getLastResultFilename($test);
|
||||
if (is_file($file)) {
|
||||
return $this->lastResults[$signature] = (int) file_get_contents($file);
|
||||
}
|
||||
|
||||
return $this->lastResults[$signature] = Test::PREPARED;
|
||||
}
|
||||
|
||||
|
||||
private function getLastResultFilename(Test $test): string
|
||||
{
|
||||
return $this->tempDir
|
||||
. DIRECTORY_SEPARATOR
|
||||
. pathinfo($test->getFile(), PATHINFO_FILENAME)
|
||||
. '.'
|
||||
. substr(md5($test->getSignature()), 0, 5)
|
||||
. '.result';
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner;
|
||||
|
||||
|
||||
/**
|
||||
* Test represents one result.
|
||||
*/
|
||||
class Test
|
||||
{
|
||||
public const
|
||||
PREPARED = 0,
|
||||
FAILED = 1,
|
||||
PASSED = 2,
|
||||
SKIPPED = 3;
|
||||
|
||||
/** @var string|null */
|
||||
public $title;
|
||||
|
||||
/** @var string|null */
|
||||
public $message;
|
||||
|
||||
/** @var string */
|
||||
public $stdout = '';
|
||||
|
||||
/** @var string */
|
||||
public $stderr = '';
|
||||
|
||||
/** @var string */
|
||||
private $file;
|
||||
|
||||
/** @var int */
|
||||
private $result = self::PREPARED;
|
||||
|
||||
/** @var string[]|string[][] */
|
||||
private $args = [];
|
||||
|
||||
|
||||
public function __construct(string $file, string $title = null)
|
||||
{
|
||||
$this->file = $file;
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
|
||||
public function getFile(): string
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return string[]|string[][]
|
||||
*/
|
||||
public function getArguments(): array
|
||||
{
|
||||
return $this->args;
|
||||
}
|
||||
|
||||
|
||||
public function getSignature(): string
|
||||
{
|
||||
$args = implode(' ', array_map(function ($arg): string {
|
||||
return is_array($arg) ? "$arg[0]=$arg[1]" : $arg;
|
||||
}, $this->args));
|
||||
|
||||
return $this->file . ($args ? " $args" : '');
|
||||
}
|
||||
|
||||
|
||||
public function getResult(): int
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
|
||||
|
||||
public function hasResult(): bool
|
||||
{
|
||||
return $this->result !== self::PREPARED;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return static
|
||||
*/
|
||||
public function withArguments(array $args): self
|
||||
{
|
||||
if ($this->hasResult()) {
|
||||
throw new \LogicException('Cannot change arguments of test which already has a result.');
|
||||
}
|
||||
|
||||
$me = clone $this;
|
||||
foreach ($args as $name => $values) {
|
||||
foreach ((array) $values as $value) {
|
||||
$me->args[] = is_int($name)
|
||||
? "$value"
|
||||
: [$name, "$value"];
|
||||
}
|
||||
}
|
||||
return $me;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return static
|
||||
*/
|
||||
public function withResult(int $result, ?string $message): self
|
||||
{
|
||||
if ($this->hasResult()) {
|
||||
throw new \LogicException("Result of test is already set to $this->result with message '$this->message'.");
|
||||
}
|
||||
|
||||
$me = clone $this;
|
||||
$me->result = $result;
|
||||
$me->message = $message;
|
||||
return $me;
|
||||
}
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This file is part of the Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tester\Runner;
|
||||
|
||||
use Tester;
|
||||
use Tester\Dumper;
|
||||
use Tester\Helpers;
|
||||
use Tester\TestCase;
|
||||
|
||||
|
||||
/**
|
||||
* Default test behavior.
|
||||
*/
|
||||
class TestHandler
|
||||
{
|
||||
private const HTTP_OK = 200;
|
||||
|
||||
/** @var Runner */
|
||||
private $runner;
|
||||
|
||||
|
||||
public function __construct(Runner $runner)
|
||||
{
|
||||
$this->runner = $runner;
|
||||
}
|
||||
|
||||
|
||||
public function initiate(string $file): void
|
||||
{
|
||||
[$annotations, $title] = $this->getAnnotations($file);
|
||||
$php = $this->runner->getInterpreter();
|
||||
|
||||
$tests = [new Test($file, $title)];
|
||||
foreach (get_class_methods($this) as $method) {
|
||||
if (!preg_match('#^initiate(.+)#', strtolower($method), $m) || !isset($annotations[$m[1]])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ((array) $annotations[$m[1]] as $value) {
|
||||
/** @var Test[] $prepared */
|
||||
$prepared = [];
|
||||
foreach ($tests as $test) {
|
||||
$res = $this->$method($test, $value, $php);
|
||||
if ($res === null) {
|
||||
$prepared[] = $test;
|
||||
} else {
|
||||
foreach (is_array($res) ? $res : [$res] as $testVariety) {
|
||||
/** @var Test $testVariety */
|
||||
if ($testVariety->hasResult()) {
|
||||
$this->runner->prepareTest($testVariety);
|
||||
$this->runner->finishTest($testVariety);
|
||||
} else {
|
||||
$prepared[] = $testVariety;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$tests = $prepared;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tests as $test) {
|
||||
$this->runner->prepareTest($test);
|
||||
$this->runner->addJob(new Job($test, $php, $this->runner->getEnvironmentVariables()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function assess(Job $job): void
|
||||
{
|
||||
$test = $job->getTest();
|
||||
$annotations = $this->getAnnotations($test->getFile())[0] += [
|
||||
'exitcode' => Job::CODE_OK,
|
||||
'httpcode' => self::HTTP_OK,
|
||||
];
|
||||
|
||||
foreach (get_class_methods($this) as $method) {
|
||||
if (!preg_match('#^assess(.+)#', strtolower($method), $m) || !isset($annotations[$m[1]])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ((array) $annotations[$m[1]] as $arg) {
|
||||
/** @var Test|null $res */
|
||||
if ($res = $this->$method($job, $arg)) {
|
||||
$this->runner->finishTest($res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->runner->finishTest($test->withResult(Test::PASSED, $test->message));
|
||||
}
|
||||
|
||||
|
||||
private function initiateSkip(Test $test, string $message): Test
|
||||
{
|
||||
return $test->withResult(Test::SKIPPED, $message);
|
||||
}
|
||||
|
||||
|
||||
private function initiatePhpVersion(Test $test, string $version, PhpInterpreter $interpreter): ?Test
|
||||
{
|
||||
if (preg_match('#^(<=|<|==|=|!=|<>|>=|>)?\s*(.+)#', $version, $matches)
|
||||
&& version_compare($matches[2], $interpreter->getVersion(), $matches[1] ?: '>=')) {
|
||||
return $test->withResult(Test::SKIPPED, "Requires PHP $version.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private function initiatePhpExtension(Test $test, string $value, PhpInterpreter $interpreter): ?Test
|
||||
{
|
||||
foreach (preg_split('#[\s,]+#', $value) as $extension) {
|
||||
if (!$interpreter->hasExtension($extension)) {
|
||||
return $test->withResult(Test::SKIPPED, "Requires PHP extension $extension.");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private function initiatePhpIni(Test $test, string $pair, PhpInterpreter &$interpreter): void
|
||||
{
|
||||
[$name, $value] = explode('=', $pair, 2) + [1 => null];
|
||||
$interpreter = $interpreter->withPhpIniOption($name, $value);
|
||||
}
|
||||
|
||||
|
||||
private function initiateDataProvider(Test $test, string $provider)
|
||||
{
|
||||
try {
|
||||
[$dataFile, $query, $optional] = Tester\DataProvider::parseAnnotation($provider, $test->getFile());
|
||||
$data = Tester\DataProvider::load($dataFile, $query);
|
||||
} catch (\Exception $e) {
|
||||
return $test->withResult(empty($optional) ? Test::FAILED : Test::SKIPPED, $e->getMessage());
|
||||
}
|
||||
|
||||
return array_map(function (string $item) use ($test, $dataFile): Test {
|
||||
return $test->withArguments(['dataprovider' => "$item|$dataFile"]);
|
||||
}, array_keys($data));
|
||||
}
|
||||
|
||||
|
||||
private function initiateMultiple(Test $test, $count): array
|
||||
{
|
||||
return array_map(function (int $i) use ($test): Test {
|
||||
return $test->withArguments(['multiple' => $i]);
|
||||
}, range(0, (int) $count - 1));
|
||||
}
|
||||
|
||||
|
||||
private function initiateTestCase(Test $test, $foo, PhpInterpreter $interpreter)
|
||||
{
|
||||
$job = new Job($test->withArguments(['method' => TestCase::LIST_METHODS]), $interpreter);
|
||||
$job->run();
|
||||
|
||||
if (in_array($job->getExitCode(), [Job::CODE_ERROR, Job::CODE_FAIL, Job::CODE_SKIP], true)) {
|
||||
return $test->withResult($job->getExitCode() === Job::CODE_SKIP ? Test::SKIPPED : Test::FAILED, $job->getTest()->stdout);
|
||||
}
|
||||
|
||||
if (!preg_match('#\[([^[]*)]#', (string) strrchr($job->getTest()->stdout, '['), $m)) {
|
||||
return $test->withResult(Test::FAILED, "Cannot list TestCase methods in file '{$test->getFile()}'. Do you call TestCase::run() in it?");
|
||||
} elseif (!strlen($m[1])) {
|
||||
return $test->withResult(Test::SKIPPED, "TestCase in file '{$test->getFile()}' does not contain test methods.");
|
||||
}
|
||||
|
||||
return array_map(function (string $method) use ($test): Test {
|
||||
return $test->withArguments(['method' => $method]);
|
||||
}, explode(',', $m[1]));
|
||||
}
|
||||
|
||||
|
||||
private function assessExitCode(Job $job, $code): ?Test
|
||||
{
|
||||
$code = (int) $code;
|
||||
if ($job->getExitCode() === Job::CODE_SKIP) {
|
||||
$message = preg_match('#.*Skipped:\n(.*?)\z#s', $output = $job->getTest()->stdout, $m)
|
||||
? $m[1]
|
||||
: $output;
|
||||
return $job->getTest()->withResult(Test::SKIPPED, trim($message));
|
||||
|
||||
} elseif ($job->getExitCode() !== $code) {
|
||||
$message = $job->getExitCode() !== Job::CODE_FAIL ? "Exited with error code {$job->getExitCode()} (expected $code)" : '';
|
||||
return $job->getTest()->withResult(Test::FAILED, trim($message . "\n" . $job->getTest()->stdout));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private function assessHttpCode(Job $job, $code): ?Test
|
||||
{
|
||||
if (!$this->runner->getInterpreter()->isCgi()) {
|
||||
return null;
|
||||
}
|
||||
$headers = $job->getHeaders();
|
||||
$actual = (int) ($headers['Status'] ?? self::HTTP_OK);
|
||||
$code = (int) $code;
|
||||
return $code && $code !== $actual
|
||||
? $job->getTest()->withResult(Test::FAILED, "Exited with HTTP code $actual (expected $code)")
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
private function assessOutputMatchFile(Job $job, string $file): ?Test
|
||||
{
|
||||
$file = dirname($job->getTest()->getFile()) . DIRECTORY_SEPARATOR . $file;
|
||||
if (!is_file($file)) {
|
||||
return $job->getTest()->withResult(Test::FAILED, "Missing matching file '$file'.");
|
||||
}
|
||||
return $this->assessOutputMatch($job, file_get_contents($file));
|
||||
}
|
||||
|
||||
|
||||
private function assessOutputMatch(Job $job, string $content): ?Test
|
||||
{
|
||||
$actual = $job->getTest()->stdout;
|
||||
if (!Tester\Assert::isMatching($content, $actual)) {
|
||||
[$content, $actual] = Tester\Assert::expandMatchingPatterns($content, $actual);
|
||||
Dumper::saveOutput($job->getTest()->getFile(), $actual, '.actual');
|
||||
Dumper::saveOutput($job->getTest()->getFile(), $content, '.expected');
|
||||
return $job->getTest()->withResult(Test::FAILED, 'Failed: output should match ' . Dumper::toLine($content));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private function getAnnotations(string $file): array
|
||||
{
|
||||
$annotations = Helpers::parseDocComment(file_get_contents($file));
|
||||
$testTitle = isset($annotations[0]) ? preg_replace('#^TEST:\s*#i', '', $annotations[0]) : null;
|
||||
return [$annotations, $testTitle];
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../CodeCoverage/Collector.php';
|
||||
|
||||
$isPhpDbg = defined('PHPDBG_VERSION');
|
||||
$extensions = get_loaded_extensions();
|
||||
natcasesort($extensions);
|
||||
|
||||
$info = (object) [
|
||||
'binary' => defined('PHP_BINARY') ? PHP_BINARY : null,
|
||||
'version' => PHP_VERSION,
|
||||
'phpDbgVersion' => $isPhpDbg ? PHPDBG_VERSION : null,
|
||||
'sapi' => PHP_SAPI,
|
||||
'iniFiles' => array_merge(
|
||||
($tmp = php_ini_loaded_file()) === false ? [] : [$tmp],
|
||||
(function_exists('php_ini_scanned_files') && strlen($tmp = (string) php_ini_scanned_files())) ? explode(",\n", trim($tmp)) : []
|
||||
),
|
||||
'extensions' => $extensions,
|
||||
'tempDir' => sys_get_temp_dir(),
|
||||
'codeCoverageEngines' => Tester\CodeCoverage\Collector::detectEngines(),
|
||||
];
|
||||
|
||||
if (isset($_SERVER['argv'][1])) {
|
||||
echo serialize($info);
|
||||
die();
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'PHP binary' => $info->binary ?: '(not available)',
|
||||
'PHP version' . ($isPhpDbg ? '; PHPDBG version' : '')
|
||||
=> "$info->version ($info->sapi)" . ($isPhpDbg ? "; $info->phpDbgVersion" : ''),
|
||||
'Loaded php.ini files' => count($info->iniFiles) ? implode(', ', $info->iniFiles) : '(none)',
|
||||
'Code coverage engines' => count($info->codeCoverageEngines) ? implode(', ', $info->codeCoverageEngines) : '(not available)',
|
||||
'PHP temporary directory' => $info->tempDir == '' ? '(empty)' : $info->tempDir,
|
||||
'Loaded extensions' => count($info->extensions) ? implode(', ', $info->extensions) : '(none)',
|
||||
] as $title => $value) {
|
||||
echo "\e[1;32m$title\e[0m:\n$value\n\n";
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Test environment initialization.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/Framework/Helpers.php';
|
||||
require __DIR__ . '/Framework/Environment.php';
|
||||
require __DIR__ . '/Framework/DataProvider.php';
|
||||
require __DIR__ . '/Framework/Assert.php';
|
||||
require __DIR__ . '/Framework/AssertException.php';
|
||||
require __DIR__ . '/Framework/Dumper.php';
|
||||
require __DIR__ . '/Framework/FileMock.php';
|
||||
require __DIR__ . '/Framework/TestCase.php';
|
||||
require __DIR__ . '/Framework/DomQuery.php';
|
||||
require __DIR__ . '/Framework/FileMutator.php';
|
||||
require __DIR__ . '/Framework/Expect.php';
|
||||
require __DIR__ . '/CodeCoverage/Collector.php';
|
||||
require __DIR__ . '/Runner/Job.php';
|
||||
|
||||
Tester\Environment::setup();
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/tester.php';
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Nette Tester.
|
||||
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/Runner/Test.php';
|
||||
require __DIR__ . '/Runner/PhpInterpreter.php';
|
||||
require __DIR__ . '/Runner/Runner.php';
|
||||
require __DIR__ . '/Runner/CliTester.php';
|
||||
require __DIR__ . '/Runner/Job.php';
|
||||
require __DIR__ . '/Runner/CommandLine.php';
|
||||
require __DIR__ . '/Runner/TestHandler.php';
|
||||
require __DIR__ . '/Runner/OutputHandler.php';
|
||||
require __DIR__ . '/Runner/Output/Logger.php';
|
||||
require __DIR__ . '/Runner/Output/TapPrinter.php';
|
||||
require __DIR__ . '/Runner/Output/ConsolePrinter.php';
|
||||
require __DIR__ . '/Runner/Output/JUnitPrinter.php';
|
||||
require __DIR__ . '/Framework/Helpers.php';
|
||||
require __DIR__ . '/Framework/Environment.php';
|
||||
require __DIR__ . '/Framework/Assert.php';
|
||||
require __DIR__ . '/Framework/AssertException.php';
|
||||
require __DIR__ . '/Framework/Dumper.php';
|
||||
require __DIR__ . '/Framework/DataProvider.php';
|
||||
require __DIR__ . '/Framework/TestCase.php';
|
||||
require __DIR__ . '/CodeCoverage/Collector.php';
|
||||
require __DIR__ . '/CodeCoverage/PhpParser.php';
|
||||
require __DIR__ . '/CodeCoverage/Generators/AbstractGenerator.php';
|
||||
require __DIR__ . '/CodeCoverage/Generators/HtmlGenerator.php';
|
||||
require __DIR__ . '/CodeCoverage/Generators/CloverXMLGenerator.php';
|
||||
|
||||
|
||||
die((new Tester\Runner\CliTester)->run());
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// creates tester.phar
|
||||
if (!class_exists('Phar') || ini_get('phar.readonly')) {
|
||||
echo "Enable Phar extension and set directive 'phar.readonly=off'.\n";
|
||||
die(1);
|
||||
}
|
||||
|
||||
|
||||
$build = @exec('git describe --tags 2>&1');
|
||||
echo "Build: $build\n";
|
||||
|
||||
@unlink('tester.phar'); // @ - file may not exist
|
||||
$phar = new Phar('tester.phar');
|
||||
$phar->setStub(
|
||||
"<?php
|
||||
// Nette Tester $build
|
||||
|
||||
if (debug_backtrace()) {
|
||||
require 'phar://' . __FILE__ . '/bootstrap.php';
|
||||
} else {
|
||||
require 'phar://' . __FILE__ . '/tester.php';
|
||||
}
|
||||
__HALT_COMPILER();
|
||||
");
|
||||
|
||||
$phar->startBuffering();
|
||||
foreach ($iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/../../src', RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
|
||||
echo "adding: {$iterator->getSubPathname()}\n";
|
||||
$phar[$iterator->getSubPathname()] = php_strip_whitespace((string) $file);
|
||||
}
|
||||
|
||||
$phar->stopBuffering();
|
||||
$phar->compressFiles(Phar::GZ);
|
||||
|
||||
echo "OK\n";
|
||||
Reference in New Issue
Block a user