no message

This commit is contained in:
BarboraFleg
2019-04-04 18:34:40 +02:00
commit ce48ca4a21
589 changed files with 82184 additions and 0 deletions
+40
View File
@@ -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" )
+27
View File
@@ -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" }
}
}
+33
View File
@@ -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:
+49
View File
@@ -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)
+213
View File
@@ -0,0 +1,213 @@
[Nette Tester](https://tester.nette.org): enjoyable unit testing
================================================================
[![Downloads this Month](https://img.shields.io/packagist/dm/nette/tester.svg)](https://packagist.org/packages/nette/tester)
[![Build Status](https://travis-ci.org/nette/tester.svg?branch=master)](https://travis-ci.org/nette/tester)
[![Build Status Windows](https://ci.appveyor.com/api/projects/status/github/nette/tester?branch=master&svg=true)](https://ci.appveyor.com/project/dg/tester/branch/master)
[![Latest Stable Version](https://poser.pugx.org/nette/tester/v/stable)](https://github.com/nette/tester/releases)
[![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](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
View File
@@ -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) ?>&nbsp;%</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
View File
@@ -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
View File
@@ -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);
}
}
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+132
View File
@@ -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;
}
}
+86
View File
@@ -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");
}
}
+87
View File
@@ -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' : '')
. ')'
);
}
}
+67
View File
@@ -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));
}
}
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/tester.php';
+36
View File
@@ -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());
+38
View File
@@ -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";