Code coverage 62 %

7 %
bootstrap.php
<?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__ '/CodeCoverage/Collector.php';
require 
__DIR__ '/Runner/Job.php';

Tester\Environment::setup();
15 %
CodeCoverage\Collector.php
<?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
{
    
/** @var resource */
    
private static $file;

    
/** @var string */
    
private static $collector;


    public static function 
isStarted(): bool
    
{
        return 
self::$file !== null;
    }


    
/**
     * Starts gathering the information for code coverage.
     * @throws \LogicException
     */
    
public static function start(string $file): void
    
{
        if (
self::isStarted()) {
            throw new \
LogicException('Code coverage collector has been already started.');
        }
        
self::$file fopen($file'c+');

        if (
defined('PHPDBG_VERSION')) {
            
phpdbg_start_oplog();
            
self::$collector 'collectPhpDbg';

        } elseif (
extension_loaded('xdebug')) {
            
xdebug_start_code_coverage(XDEBUG_CC_UNUSED XDEBUG_CC_DEAD_CODE);
            
self::$collector 'collectXdebug';

        } elseif (
extension_loaded('pcov')) {
            \
pcov\start();
            
self::$collector 'collectPCOV';

        } else {
            throw new \
LogicException('Code coverage functionality requires Xdebug extension, phpdbg SAPI or PCOV extension.');
        }

        
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::$collector === 'collectPhpDbg') {
            
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] = [__CLASS__self::$collector]();

        
flock(self::$fileLOCK_EX);
        
fseek(self::$file0);
        
$rawContent stream_get_contents(self::$file);
        
$original $rawContent unserialize($rawContent) : [];
        
$coverage array_replace_recursive($negative$original$positive);

        
fseek(self::$file0);
        
ftruncate(self::$file0);
        
fwrite(self::$fileserialize($coverage));
        
flock(self::$fileLOCK_UN);
    }


    
/**
     * 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];
    }


    
/**
     * 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];
    }


    
/**
     * 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];
    }
}
65 %
CodeCoverage\Generators\AbstractGenerator.php
<?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;


/**
 * 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 = [self::getCommonFilesPath(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->acceptFilestrue);
        });
    }


    protected static function 
getCommonFilesPath(array $files): string
    
{
        
$path reset($files);
        for (
$i 0$i strlen($path); $i++) {
            foreach (
$files as $file) {
                if (!isset(
$file[$i]) || $path[$i] !== $file[$i]) {
                    
$path substr($path0$i);
                    break 
2;
                }
            }
        }

        return 
rtrim(is_dir($path) ? $path dirname($path), DIRECTORY_SEPARATOR);
    }


    abstract protected function 
renderSelf(): void;
}
99 %
CodeCoverage\Generators\CloverXMLGenerator.php
<?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($fileFILE_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($name0$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;
            }

            
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 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 %
CodeCoverage\Generators\HtmlGenerator.php
<?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;


/**
 * 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 self::getCommonFilesPath($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($entryFILE_SKIP_EMPTY_LINES));
            }

            
$light $total $total 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 %
CodeCoverage\Generators\template.phtml
<!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($codetrue), [
                
'<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>
100 %
CodeCoverage\PhpParser.php
<?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($codeTOKEN_PARSE);

        
$level $classLevel $functionLevel null;
        
$namespace '';
        
$line 1;

        
$result = (object) [
            
'linesOfCode' => max(1substr_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_STRINGT_NS_SEPARATOR]) . '\\''\\');
                    break;

                case 
T_CLASS:
                case 
T_INTERFACE:
                case 
T_TRAIT:
                    if (
$name self::fetch($tokensT_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($tokensT_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) $taketrue)) {
                
$res .= $s;
            } elseif (!
in_array($token, [T_DOC_COMMENTT_WHITESPACET_COMMENT], true)) {
                break;
            }
            
next($tokens);
        }
        return 
$res;
    }
}
97 %
Framework\Assert.php
<?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;


    
/**
     * Checks assertion. Values must be exactly the same.
     */
    
public static function same($expected$actualstring $description null): void
    
{
        
self::$counter++;
        if (
$actual !== $expected) {
            
self::fail(self::describe('%1 should be %2'$description), $actual$expected);
        }
    }


    
/**
     * Checks assertion. Values must not be exactly the same.
     */
    
public static function notSame($expected$actualstring $description null): void
    
{
        
self::$counter++;
        if (
$actual === $expected) {
            
self::fail(self::describe('%1 should not be %2'$description), $actual$expected);
        }
    }


    
/**
     * Checks assertion. The identity of objects and the order of keys in the arrays are ignored.
     */
    
public static function equal($expected$actualstring $description null): void
    
{
        
self::$counter++;
        if (!
self::isEqual($expected$actual)) {
            
self::fail(self::describe('%1 should be equal to %2'$description), $actual$expected);
        }
    }


    
/**
     * Checks assertion. The identity of objects and the order of keys in the arrays are ignored.
     */
    
public static function notEqual($expected$actualstring $description null): void
    
{
        
self::$counter++;
        if (
self::isEqual($expected$actual)) {
            
self::fail(self::describe('%1 should not be equal to %2'$description), $actual$expected);
        }
    }


    
/**
     * Checks assertion. Values must contains expected needle.
     */
    
public static function contains($needle$actualstring $description null): void
    
{
        
self::$counter++;
        if (
is_array($actual)) {
            if (!
in_array($needle$actualtrue)) {
                
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);
        }
    }


    
/**
     * Checks assertion. Values must not contains expected needle.
     */
    
public static function notContains($needle$actualstring $description null): void
    
{
        
self::$counter++;
        if (
is_array($actual)) {
            if (
in_array($needle$actualtrue)) {
                
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);
        }
    }


    
/**
     * Checks TRUE assertion.
     * @param  mixed  $actual
     */
    
public static function true($actualstring $description null): void
    
{
        
self::$counter++;
        if (
$actual !== true) {
            
self::fail(self::describe('%1 should be TRUE'$description), $actual);
        }
    }


    
/**
     * Checks FALSE assertion.
     * @param  mixed  $actual
     */
    
public static function false($actualstring $description null): void
    
{
        
self::$counter++;
        if (
$actual !== false) {
            
self::fail(self::describe('%1 should be FALSE'$description), $actual);
        }
    }


    
/**
     * Checks NULL assertion.
     * @param  mixed  $actual
     */
    
public static function null($actualstring $description null): void
    
{
        
self::$counter++;
        if (
$actual !== null) {
            
self::fail(self::describe('%1 should be NULL'$description), $actual);
        }
    }


    
/**
     * Checks Not a Number assertion.
     * @param  mixed  $actual
     */
    
public static function nan($actualstring $description null): void
    
{
        
self::$counter++;
        if (!
is_float($actual) || !is_nan($actual)) {
            
self::fail(self::describe('%1 should be NAN'$description), $actual);
        }
    }


    
/**
     * Checks truthy assertion.
     * @param  mixed  $actual
     */
    
public static function truthy($actualstring $description null): void
    
{
        
self::$counter++;
        if (!
$actual) {
            
self::fail(self::describe('%1 should be truthy'$description), $actual);
        }
    }


    
/**
     * Checks falsey (empty) assertion.
     * @param  mixed  $actual
     */
    
public static function falsey($actualstring $description null): void
    
{
        
self::$counter++;
        if (
$actual) {
            
self::fail(self::describe('%1 should be falsey'$description), $actual);
        }
    }


    
/**
     * Checks if subject has expected count.
     * @param  mixed  $value
     */
    
public static function count(int $count$valuestring $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);
        }
    }


    
/**
     * Checks assertion.
     * @param  string|object  $type
     * @param  mixed  $value
     */
    
public static function type($type$valuestring $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(0count($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));
        }
    }


    
/**
     * Checks if the function throws exception.
     */
    
public static function exception(callable $functionstring $classstring $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()})" ''), nullnull$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;
    }


    
/**
     * Checks if the function throws exception, alias for exception().
     */
    
public static function throws(callable $functionstring $classstring $message null$code null): ?\Throwable
    
{
        return 
self::exception($function$class$message$code);
    }


    
/**
     * Checks if the function generates PHP error or throws exception.
     * @param  int|string|array $expectedType
     * @param  string $expectedMessage message
     * @throws \Exception
     * @throws \Exception
     */
    
public static function error(callable $function$expectedTypestring $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) + [nullnull];
            
$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 $severitystring $messagestring $fileint $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;
    }


    
/**
     * Checks that the function does not generate PHP error and does not throw exception.
     */
    
public static function noError(callable $function): void
    
{
        
self::error($function, []);
    }


    
/**
     * Compares result using regular expression or mask:
     *   %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$actualstring $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);
        }
    }


    
/**
     * Compares results using mask sorted in file.
     */
    
public static function matchFile(string $file$actualstring $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);
        }
    }


    
/**
     * Failed assertion
     */
    
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 $reasonstring $description null): string
    
{
        return (
$description $description ': ' '') . $reason;
    }


    public static function 
with($obj, \Closure $closure)
    {
        return 
$closure->bindTo($obj$obj)->__invoke();
    }


    
/********************* helpers ****************d*g**/


    /**
     * Compares using mask.
     * @internal
     */
    
public static function isMatching(string $pattern$actualbool $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, -1PREG_SPLIT_DELIM_CAPTURE);
        for (
$i count($parts); $i >= 0$i--) {
            
$patternX implode(array_slice($parts0$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($patternX0, -strlen($greedyPattern));
                
$patternY "$patternX%A?%";
                
$patternZ $greedyPattern $patternZ;
                break;
            }
        }

        
$low 0;
        
$high strlen($actual);
        while (
$low <= $high) {
            
$mid = ($low $high) >> 1;
            if (
self::isMatching($patternYsubstr($actual0$mid))) {
                
$high $mid 1;
            } else {
                
$low $mid 1;
            }
        }

        
$low $high 2;
        
$high strlen($actual);
        while (
$low <= $high) {
            
$mid = ($low $high) >> 1;
            if (!
self::isMatching($patternXsubstr($actual0$mid), true)) {
                
$high $mid 1;
            } else {
                
$low $mid 1;
            }
        }

        
$actualX substr($actual0$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. Ignores the identity of objects and the order of keys in the arrays.
     */
    
private static function isEqual($expected$actualint $level 0$objects null): bool
    
{
        if (
$level 10) {
            throw new \
Exception('Nesting level too deep or recursive dependency.');
        }

        if (
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);
        }

        if (
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;
        }

        if (
is_array($expected) && is_array($actual)) {
            
ksort($expectedSORT_STRING);
            
ksort($actualSORT_STRING);
            if (
array_keys($expected) !== array_keys($actual)) {
                return 
false;
            }

            foreach (
$expected as $value) {
                if (!
self::isEqual($valuecurrent($actual), $level 1$objects)) {
                    return 
false;
                }
                
next($actual);
            }
            return 
true;
        }

        return 
$expected === $actual;
    }


    private static function 
isPcre(string $pattern): bool
    
{
        return (bool) 
preg_match('/^([~#]).+(\1)[imsxUu]*\z/s'$pattern);
    }
}
100 %
Framework\AssertException.php
<?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;
    }
}
67 %
Framework\DataProvider.php
<?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 $filestring $query ''): array
    {
        if (!
is_file($file)) {
            throw new \
Exception("Missing data-provider file '$file'.");
        }

        if (
pathinfo($filePATHINFO_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($filetrue); // @ 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 $inputstring $query): bool
    
{
        static 
$replaces = ['' => '=''=>' => '>=''=<' => '<='];
        
$tokens preg_split('#\s+#'$input);
        
preg_match_all('#\s*,?\s*(<=|=<|<|==|=|!=|<>|>=|=>|>)?\s*([^\s,]+)#A'$query$queryPartsPREG_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($lstring $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 $annotationstring $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]];
    }
}
98 %
Framework\DomQuery.php
<?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), $matchesPREG_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;
    }
}
97 %
Framework\Dumper.php
<?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($vartrue);

        } elseif (
is_string($var)) {
            if (
preg_match('#^(.{' self::$maxLength '}).#su'$var$m)) {
                
$var "$m[1]...";
            } elseif (
strlen($var) > self::$maxLength) {
                
$var substr($var0self::$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 (
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)), 04);
    }


    private static function 
_toPhp(&$var, array &$list = [], int $level 0int &$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 (
$k[0] === "\x00") {
                        
$k substr($kstrrpos($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($vartrue);
            
$line += substr_count($res"\n");
            return 
$res;
        }
    }


    
/**
     * @internal
     */
    
public static function dumpException(\Throwable $e): string
    
{
        
$trace $e->getTrace();
        
array_splice($trace0$e instanceof \ErrorException 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(0min(
                    
$i - (int) (self::$maxLength 3), // try to display 1/3 of shorter string
                    
max(strlen($actual), strlen($expected)) - self::$maxLength // 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'] === 'Tester\Assert' && 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_SEPARATORarray_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$contentstring $suffix ''): string
    
{
        
$path self::$dumpDir DIRECTORY_SEPARATOR pathinfo($testFilePATHINFO_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($pathis_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);
    }
}
44 %
Framework\Environment.php
<?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 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('Tester\Runner\Job');
        
class_exists('Tester\Dumper');
        
class_exists('Tester\Assert');

        
$annotations self::getTestAnnotations();
        
self::$checkAssertions = !isset($annotations['outputmatch']) && !isset($annotations['outputmatchfile']);

        if (
getenv(self::COVERAGE)) {
            
CodeCoverage\Collector::start(getenv(self::COVERAGE));
        }
    }


    
/**
     * 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);
        }, 
1PHP_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 $severitystring $messagestring $fileint $line): ?bool {
            if (
in_array($severity, [E_RECOVERABLE_ERRORE_USER_ERROR], true) || ($severity error_reporting()) === $severity) {
                
self::handleException(new \ErrorException($message0$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_ERRORE_CORE_ERRORE_COMPILE_ERRORE_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($codeTOKEN_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
    
}
}
96 %
Framework\FileMock.php
<?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::PROTOCOLstream_get_wrappers(), true)) {
            
stream_wrapper_register(self::PROTOCOL__CLASS__);
        }
    }


    public function 
stream_open(string $pathstring $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 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 $offsetint $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 $pathint $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(03);
        if (isset(
$bt[2]['function'])) {
            
$message $bt[2]['function'] . '(' . @$bt[2]['args'][0] . '): ' $message;
        }

        
trigger_error($messageE_USER_WARNING);
    }
}
74 %
Framework\FileMutator.php
<?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 $pathint $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 $pathint $modeint $options): bool
    
{
        return 
$this->native('mkdir'$path$modefalse$this->context);
    }


    public function 
rename(string $pathFromstring $pathTo): bool
    
{
        return 
$this->native('rename'$pathFrom$pathTo$this->context);
    }


    public function 
rmdir(string $pathint $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 $pathint $option$value): bool
    
{
        switch (
$option) {
            case 
STREAM_META_TOUCH:
                
$value += [nullnull];
                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 $pathstring $modeint $options, ?string &$openedPath): bool
    
{
        
$usePath = (bool) ($options STREAM_USE_PATH);
        if (
pathinfo($pathPATHINFO_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->handle0);
                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 $offsetint $whence SEEK_SET): bool
    
{
        return 
fseek($this->handle$offset$whence) === 0;
    }


    public function 
stream_set_option(int $optionint $arg1int $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 $pathint $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;
    }
}
81 %
Framework\Helpers.php
<?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 (!
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);
            }
        }
    }


    
/**
     * 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], $matchesPREG_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($name02) === '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);
    }
}
98 %
Framework\TestCase.php
<?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_PATTERNarray_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
{
}
0 %
Runner\CliTester.php
<?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;


/**
 * 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;
        }

        if (
$this->options['--coverage']) {
            
$coverageFile $this->prepareCodeCoverage();
        }

        
$runner $this->createRunner();
        
$runner->setEnvironmentVariable(Environment::RUNNER'1');
        
$runner->setEnvironmentVariable(Environment::COLORS, (string) (int) Environment::$useColors);
        if (isset(
$coverageFile)) {
            
$runner->setEnvironmentVariable(Environment::COVERAGE$coverageFile);
        }

        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 1;
    }


    private function 
loadOptions(): CommandLine
    
{
        echo <<<'XX'
 _____ ___  ___ _____ ___  ___
|_   _/ __)( __/_   _/ __)| _ )
  |_| \___ /___) |_| \___ |_|_\  v2.1.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 => trueCommandLine::REALPATH => true],
            
'--setup' => [CommandLine::REALPATH => true],
            
'--temp' => [CommandLine::REALPATH => true],
            
'paths' => [CommandLine::REPEATABLE => trueCommandLine::VALUE => getcwd()],
            
'--debug' => [],
            
'--coverage-src' => [CommandLine::REALPATH => trueCommandLine::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($realDIRECTORY_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']);
        }

        if (
$this->options['-o'] !== 'none') {
            switch (
$this->options['-o']) {
                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']);
            }
        }

        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(): string
    
{
        if (!
$this->interpreter->canMeasureCodeCoverage()) {
            throw new \
Exception("Code coverage functionality requires Xdebug extension, phpdbg SAPI or PCOV extension (used {$this->interpreter->getCommandLine()})");
        }
        
file_put_contents($this->options['--coverage'], '');
        
$file realpath($this->options['--coverage']);
        echo 
"Code coverage: {$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($filePATHINFO_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(), 03) === 'php' && substr($file->getBasename(), 01) !== '.') {
                        
$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";
            }
            echo 
'Watching ' implode(', '$this->options['--watch']) . ' ' 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 $severitystring $messagestring $fileint $line) {
            if ((
$severity error_reporting()) === $severity) {
                throw new \
ErrorException($message0$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";
    }
}
95 %
Runner\CommandLine.php
<?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$linesPREG_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]) > || 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]), 01) === '[') || isset($opts[self::VALUE]),
                
self::REPEATABLE => (bool) end($m[3]),
                
self::ENUM => count($enums explode('|'trim(end($m[2]), '<[]>'))) > $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('='$arg2) : [$argtrue];

            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;
    }
}
0 %
Runner\info.php
<?php

/**
 * @internal
 */

declare(strict_types=1);

$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(),
    
'canMeasureCodeCoverage' => $isPhpDbg || in_array('xdebug'$extensionstrue) || in_array('pcov'$extensionstrue),
];

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)',
    
'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";
}
85 %
Runner\Job.php
<?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 $testPhpInterpreter $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 $namestring $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->stdoutfalse); // on Windows does not work with proc_open()
            
if ($this->stderr) {
                
stream_set_blocking($this->stderrfalse);
            }
        } 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->stdout2)) >= 2) {
            [
$headers$this->test->stdout] = $tmp;
            foreach (
explode("\r\n"$headers) as $header) {
                
$pos strpos($header':');
                if (
$pos !== false) {
                    
$this->headers[trim(substr($header0$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;
    }
}
83 %
Runner\Output\ConsolePrinter.php
<?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;


    public function 
__construct(Runner $runnerbool $displaySkipped falsestring $file 'php://output')
    {
        
$this->runner $runner;
        
$this->displaySkipped $displaySkipped;
        
$this->file fopen($file'w');
    }


    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 '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()]++;
        
$outputs = [
            
Test::PASSED => '.',
            
Test::SKIPPED => 's',
            
Test::FAILED => Dumper::color('white/red''F'),
        ];
        
fwrite($this->file$outputs[$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 's' '') . ', '
            
. ($this->results[Test::FAILED] ? $this->results[Test::FAILED] . ' failure' . ($this->results[Test::FAILED] > '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;
    }
}
100 %
Runner\Output\JUnitPrinter.php
<?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");
    }
}
100 %
Runner\Output\Logger.php
<?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 $runnerstring $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' '')
            . 
')'
        
);
    }
}
100 %
Runner\Output\TapPrinter.php
<?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));
    }
}
100 %
Runner\OutputHandler.php
<?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;
}
88 %
Runner\PhpInterpreter.php
<?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(['Tester\Helpers''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"$output2);
        
$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 $namestring $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 
canMeasureCodeCoverage(): bool
    
{
        return 
$this->info->canMeasureCodeCoverage;
    }


    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);
    }
}
65 %
Runner\Runner.php
<?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 $namestring $value): void
    
{
        
$this->envVars[$name] = $value;
    }


    public function 
getEnvironmentVariables(): array
    {
        return 
$this->envVars;
    }


    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 $aJob $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 && (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(SIGINTSIG_DFL);
                
$this->interrupted true;
            });
        }
    }


    private function 
removeInterruptHandler(): void
    
{
        if (
extension_loaded('pcntl')) {
            
pcntl_signal(SIGINTSIG_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()), 05)
            . 
'.result';
    }
}
100 %
Runner\Test.php
<?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 $filestring $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;
    }
}
95 %
Runner\TestHandler.php
<?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 $teststring $message): Test
    
{
        return 
$test->withResult(Test::SKIPPED$message);
    }


    private function 
initiatePhpVersion(Test $teststring $versionPhpInterpreter $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 $teststring $valuePhpInterpreter $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 $teststring $pairPhpInterpreter &$interpreter): void
    
{
        [
$name$value] = explode('='$pair2) + [=> null];
        
$interpreter $interpreter->withPhpIniOption($name$value);
    }


    private function 
initiateDataProvider(Test $teststring $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$fooPhpInterpreter $interpreter)
    {
        
$job = new Job($test->withArguments(['method' => TestCase::LIST_METHODS]), $interpreter);
        
$job->run();

        if (
in_array($job->getExitCode(), [Job::CODE_ERRORJob::CODE_FAILJob::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::SKIPPEDtrim($message));

        } elseif (
$job->getExitCode() !== $code) {
            
$message $job->getExitCode() !== Job::CODE_FAIL "Exited with error code {$job->getExitCode()} (expected $code)" '';
            return 
$job->getTest()->withResult(Test::FAILEDtrim($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 $jobstring $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($jobfile_get_contents($file));
    }


    private function 
assessOutputMatch(Job $jobstring $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];
    }
}
0 %
tester.php
<?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/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());