Code coverage 61 %
<?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();
<?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::$file, LOCK_EX);
fseek(self::$file, 0);
$rawContent = stream_get_contents(self::$file);
$original = $rawContent ? unserialize($rawContent) : [];
$coverage = array_replace_recursive($negative, $original, $positive);
fseek(self::$file, 0);
ftruncate(self::$file, 0);
fwrite(self::$file, serialize($coverage));
flock(self::$file, LOCK_UN);
}
/**
* 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];
}
}
<?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->acceptFiles, true);
});
}
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($path, 0, $i);
break 2;
}
}
}
return rtrim(is_dir($path) ? $path : dirname($path), DIRECTORY_SEPARATOR);
}
abstract protected function renderSelf(): void;
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester\CodeCoverage\Generators;
use DOMDocument;
use DOMElement;
use Tester\CodeCoverage\PhpParser;
class CloverXMLGenerator extends AbstractGenerator
{
private static $metricAttributesMap = [
'packageCount' => 'packages',
'fileCount' => 'files',
'linesOfCode' => 'loc',
'linesOfNonCommentedCode' => 'ncloc',
'classCount' => 'classes',
'methodCount' => 'methods',
'coveredMethodCount' => 'coveredmethods',
'statementCount' => 'statements',
'coveredStatementCount' => 'coveredstatements',
'elementCount' => 'elements',
'coveredElementCount' => 'coveredelements',
'conditionalCount' => 'conditionals',
'coveredConditionalCount' => 'coveredconditionals',
];
public function __construct(string $file, array $sources = [])
{
if (!extension_loaded('dom')) {
throw new \LogicException('CloverXML generator requires DOM extension to be loaded.');
}
parent::__construct($file, $sources);
}
protected function renderSelf(): void
{
$time = (string) time();
$parser = new PhpParser;
$doc = new DOMDocument;
$doc->formatOutput = true;
$elCoverage = $doc->appendChild($doc->createElement('coverage'));
$elCoverage->setAttribute('generated', $time);
// TODO: @name
$elProject = $elCoverage->appendChild($doc->createElement('project'));
$elProject->setAttribute('timestamp', $time);
$elProjectMetrics = $elProject->appendChild($doc->createElement('metrics'));
$projectMetrics = (object) [
'packageCount' => 0,
'fileCount' => 0,
'linesOfCode' => 0,
'linesOfNonCommentedCode' => 0,
'classCount' => 0,
'methodCount' => 0,
'coveredMethodCount' => 0,
'statementCount' => 0,
'coveredStatementCount' => 0,
'elementCount' => 0,
'coveredElementCount' => 0,
'conditionalCount' => 0,
'coveredConditionalCount' => 0,
];
foreach ($this->getSourceIterator() as $file) {
$file = (string) $file;
$projectMetrics->fileCount++;
if (empty($this->data[$file])) {
$coverageData = null;
$this->totalSum += count(file($file, FILE_SKIP_EMPTY_LINES));
} else {
$coverageData = $this->data[$file];
}
// TODO: split to <package> by namespace?
$elFile = $elProject->appendChild($doc->createElement('file'));
$elFile->setAttribute('name', $file);
$elFileMetrics = $elFile->appendChild($doc->createElement('metrics'));
$code = $parser->parse(file_get_contents($file));
$fileMetrics = (object) [
'linesOfCode' => $code->linesOfCode,
'linesOfNonCommentedCode' => $code->linesOfCode - $code->linesOfComments,
'classCount' => count($code->classes) + count($code->traits),
'methodCount' => 0,
'coveredMethodCount' => 0,
'statementCount' => 0,
'coveredStatementCount' => 0,
'elementCount' => 0,
'coveredElementCount' => 0,
'conditionalCount' => 0,
'coveredConditionalCount' => 0,
];
foreach (array_merge($code->classes, $code->traits) as $name => $info) { // TODO: interfaces?
$elClass = $elFile->appendChild($doc->createElement('class'));
if (($tmp = strrpos($name, '\\')) === false) {
$elClass->setAttribute('name', $name);
} else {
$elClass->setAttribute('namespace', substr($name, 0, $tmp));
$elClass->setAttribute('name', substr($name, $tmp + 1));
}
$elClassMetrics = $elClass->appendChild($doc->createElement('metrics'));
$classMetrics = $this->calculateClassMetrics($info, $coverageData);
self::setMetricAttributes($elClassMetrics, $classMetrics);
self::appendMetrics($fileMetrics, $classMetrics);
}
self::setMetricAttributes($elFileMetrics, $fileMetrics);
foreach ((array) $coverageData as $line => $count) {
if ($count === self::CODE_DEAD) {
continue;
}
// Line type can be 'method' but Xdebug does not report such lines as executed.
$elLine = $elFile->appendChild($doc->createElement('line'));
$elLine->setAttribute('num', (string) $line);
$elLine->setAttribute('type', 'stmt');
$elLine->setAttribute('count', (string) max(0, $count));
$this->totalSum++;
$this->coveredSum += $count > 0 ? 1 : 0;
}
self::appendMetrics($projectMetrics, $fileMetrics);
}
// TODO: What about reported (covered) lines outside of class/trait definition?
self::setMetricAttributes($elProjectMetrics, $projectMetrics);
echo $doc->saveXML();
}
private function calculateClassMetrics(\stdClass $info, array $coverageData = null): \stdClass
{
$stats = (object) [
'methodCount' => count($info->methods),
'coveredMethodCount' => 0,
'statementCount' => 0,
'coveredStatementCount' => 0,
'conditionalCount' => 0,
'coveredConditionalCount' => 0,
'elementCount' => null,
'coveredElementCount' => null,
];
foreach ($info->methods as $name => $methodInfo) {
[$lineCount, $coveredLineCount] = $this->analyzeMethod($methodInfo, $coverageData);
$stats->statementCount += $lineCount;
if ($coverageData !== null) {
$stats->coveredMethodCount += $lineCount === $coveredLineCount ? 1 : 0;
$stats->coveredStatementCount += $coveredLineCount;
}
}
$stats->elementCount = $stats->methodCount + $stats->statementCount;
$stats->coveredElementCount = $stats->coveredMethodCount + $stats->coveredStatementCount;
return $stats;
}
private static function analyzeMethod(\stdClass $info, array $coverageData = null): array
{
$count = 0;
$coveredCount = 0;
if ($coverageData === null) { // Never loaded file
$count = max(1, $info->end - $info->start - 2);
} else {
for ($i = $info->start; $i <= $info->end; $i++) {
if (isset($coverageData[$i]) && $coverageData[$i] !== self::CODE_DEAD) {
$count++;
if ($coverageData[$i] > 0) {
$coveredCount++;
}
}
}
}
return [$count, $coveredCount];
}
private static function appendMetrics(\stdClass $summary, \stdClass $add): void
{
foreach ($add as $name => $value) {
$summary->{$name} += $value;
}
}
private static function setMetricAttributes(DOMElement $element, \stdClass $metrics): void
{
foreach ($metrics as $name => $value) {
$element->setAttribute(self::$metricAttributesMap[$name], (string) $value);
}
}
}
<?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($entry, FILE_SKIP_EMPTY_LINES));
}
$light = $total ? $total < 5 : count(file($entry)) < 50;
$this->files[] = (object) [
'name' => str_replace($commonSourcesPath, '', $entry),
'file' => $entry,
'lines' => $lines,
'coverage' => $coverage,
'total' => $total,
'class' => $light ? 'light' : ($loaded ? null : 'not-loaded'),
];
}
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex,noarchive">
<meta name="generator" content="Nette Tester">
<title><?= $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage</title>
<style type="text/css">
html {
font: 14px/1.5 Verdana,"Geneva CE",lucida,sans-serif;
border-top: 4.7em solid #f4ebdb;
}
body {
max-width: 990px;
margin: -4.7em auto 0;
background: #fcfaf5;
color: #333;
}
footer {
margin-left: .5em;
}
h1 {
font-family: "Trebuchet MS","Geneva CE",lucida,sans-serif;
font-size: 1.9em;
margin: .5em .5em 1.5em;
color: #7a7772;
text-shadow: 1px 1px 0 white;
}
div.code {
background: white;
border: 1px dotted silver;
padding: .4em 0;
display: none;
color: #333;
overflow: auto;
}
code,
div.code {
font: 13px/1.3 monospace;
}
div.code > div {
float: left;
min-width: 100%;
position: relative;
}
aside {
min-width: 100%;
position: absolute;
}
aside div {
white-space: pre;
padding-left: .7em;
}
aside a {
color: #c0c0c0;
}
aside a:hover {
color: inherit;
font-weight: bold;
text-decoration: none;
}
code {
display: block;
white-space: nowrap;
position: relative;
}
a {
color: #006aeb;
text-decoration: none;
}
a:active,
a:hover {
text-decoration: underline;
}
td {
vertical-align: middle;
}
small {
color: gray;
}
.number {
text-align: right;
width: 50px;
}
.bar {
border: 1px solid #acacac;
background: #e50400;
width: 35px;
height: 1em;
}
.bar div {
background: #1a7e1e;
height: 1em;
}
.light td {
opacity: .5;
}
.light td * {
color: gray;
}
.not-loaded td * {
color: red;
}
.t { background-color: #e0ffe0; }
.u { background-color: #ffe0e0; }
code .hc { color: #929292; }
code .hd { color: #333; }
code .hh { color: #06B; }
code .hk { color: #e71818; }
code .hs { color: #008000; }
</style>
</head>
<body>
<h1><?= $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage <?= round($coveredPercent) ?> %</h1>
<?php foreach ($files as $id => $info): ?>
<div>
<table>
<tr<?= $info->class ? " class='$info->class'" : '' ?>>
<td class="number"><small><?= $info->coverage ?> %</small></td>
<td><div class="bar"><div style="width: <?= $info->coverage ?>%"></div></div></td>
<td><a href="#F<?= $id ?>" class="toggle"><?= $info->name ?></a></td>
</tr>
</table>
<div class="code" id="F<?= $id ?>">
<div>
<aside>
<?php
$code = file_get_contents($info->file);
$lineCount = substr_count($code, "\n") + 1;
$digits = ceil(log10($lineCount)) + 1;
$prevClass = null;
$closeTag = $buffer = '';
for ($i = 1; $i < $lineCount; $i++) {
$class = isset($info->lines[$i]) && isset($classes[$info->lines[$i]]) ? $classes[$info->lines[$i]] : '';
if ($class !== $prevClass) {
echo rtrim($buffer) . $closeTag;
$buffer = '';
$closeTag = '</div>';
echo '<div' . ($class ? " class='$class'" : '') . '>';
}
$buffer .= "<a href='#F{$id}L{$i}' id='F{$id}L{$i}'>" . sprintf("%{$digits}s", $i) . "</a>\n";
$prevClass = $class;
}
echo $buffer . $closeTag;
$code = strtr(highlight_string($code, true), [
'<code>' => "<code style='margin-left: {$digits}em'>",
'<span style="color: ' => '<span class="',
]);
?>
</aside>
<?= $code ?>
</div>
</div>
</div>
<?php endforeach ?>
<footer>
<p>Generated by <a href="https://tester.nette.org">Nette Tester</a> at <?= @date('Y/m/d H:i:s') // @ timezone may not be set ?></p>
</footer>
<script>
document.body.addEventListener('click', function (e) {
if (e.target.className === 'toggle') {
var el = document.getElementById(e.target.href.split('#')[1]);
if (el.style.display === 'block') {
el.style.display = 'none';
} else {
el.style.display = 'block';
}
e.preventDefault();
}
});
if (el = document.getElementById(window.location.hash.replace(/^#|L\d+$/g, ''))) {
el.style.display = 'block';
}
</script>
</body>
</html>
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester\CodeCoverage;
/**
* Parses PHP source code and returns:
* - the start/end line information about functions, classes, interfaces, traits and their methods
* - the count of code lines
* - the count of commented code lines
*
* @internal
*/
class PhpParser
{
/**
* Returned structure is:
* stdClass {
* linesOfCode: int,
* linesOfComments: int,
* functions: [functionName => $functionInfo],
* classes: [className => $info],
* traits: [traitName => $info],
* interfaces: [interfaceName => $info],
* }
*
* where $functionInfo is:
* stdClass {
* start: int,
* end: int
* }
*
* and $info is:
* stdClass {
* start: int,
* end: int,
* methods: [methodName => $methodInfo]
* }
*
* where $methodInfo is:
* stdClass {
* start: int,
* end: int,
* visibility: public|protected|private
* }
*/
public function parse(string $code): \stdClass
{
$tokens = token_get_all($code, TOKEN_PARSE);
$level = $classLevel = $functionLevel = null;
$namespace = '';
$line = 1;
$result = (object) [
'linesOfCode' => max(1, substr_count($code, "\n")),
'linesOfComments' => 0,
'functions' => [],
'classes' => [],
'traits' => [],
'interfaces' => [],
];
while ($token = current($tokens)) {
next($tokens);
if (is_array($token)) {
$line = $token[2];
}
switch (is_array($token) ? $token[0] : $token) {
case T_NAMESPACE:
$namespace = ltrim(self::fetch($tokens, [T_STRING, T_NS_SEPARATOR]) . '\\', '\\');
break;
case T_CLASS:
case T_INTERFACE:
case T_TRAIT:
if ($name = self::fetch($tokens, T_STRING)) {
if ($token[0] === T_CLASS) {
$class = &$result->classes[$namespace . $name];
} elseif ($token[0] === T_INTERFACE) {
$class = &$result->interfaces[$namespace . $name];
} else {
$class = &$result->traits[$namespace . $name];
}
$classLevel = $level + 1;
$class = (object) [
'start' => $line,
'end' => null,
'methods' => [],
];
}
break;
case T_PUBLIC:
case T_PROTECTED:
case T_PRIVATE:
$visibility = $token[1];
break;
case T_ABSTRACT:
$isAbstract = true;
break;
case T_FUNCTION:
if (($name = self::fetch($tokens, T_STRING)) && !isset($isAbstract)) {
if (isset($class) && $level === $classLevel) {
$function = &$class->methods[$name];
$function = (object) [
'start' => $line,
'end' => null,
'visibility' => $visibility ?? 'public',
];
} else {
$function = &$result->functions[$namespace . $name];
$function = (object) [
'start' => $line,
'end' => null,
];
}
$functionLevel = $level + 1;
}
unset($visibility, $isAbstract);
break;
case T_CURLY_OPEN:
case T_DOLLAR_OPEN_CURLY_BRACES:
case '{':
$level++;
break;
case '}':
if (isset($function) && $level === $functionLevel) {
$function->end = $line;
unset($function);
} elseif (isset($class) && $level === $classLevel) {
$class->end = $line;
unset($class);
}
$level--;
break;
case T_COMMENT:
case T_DOC_COMMENT:
$result->linesOfComments += substr_count(trim($token[1]), "\n") + 1;
// break omitted
case T_WHITESPACE:
case T_CONSTANT_ENCAPSED_STRING:
$line += substr_count($token[1], "\n");
break;
}
}
return $result;
}
private static function fetch(array &$tokens, $take): ?string
{
$res = null;
while ($token = current($tokens)) {
[$token, $s] = is_array($token) ? $token : [$token, $token];
if (in_array($token, (array) $take, true)) {
$res .= $s;
} elseif (!in_array($token, [T_DOC_COMMENT, T_WHITESPACE, T_COMMENT], true)) {
break;
}
next($tokens);
}
return $res;
}
}
<?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, $actual, string $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, $actual, string $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, $actual, string $description = null): void
{
self::$counter++;
if (!self::isEqual($expected, $actual)) {
self::fail(self::describe('%1 should be equal to %2', $description), $actual, $expected);
}
}
/**
* Checks assertion. The identity of objects and the order of keys in the arrays are ignored.
*/
public static function notEqual($expected, $actual, string $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, $actual, string $description = null): void
{
self::$counter++;
if (is_array($actual)) {
if (!in_array($needle, $actual, true)) {
self::fail(self::describe('%1 should contain %2', $description), $actual, $needle);
}
} elseif (is_string($actual)) {
if ($needle !== '' && strpos($actual, $needle) === false) {
self::fail(self::describe('%1 should contain %2', $description), $actual, $needle);
}
} else {
self::fail(self::describe('%1 should be string or array', $description), $actual);
}
}
/**
* Checks assertion. Values must not contains expected needle.
*/
public static function notContains($needle, $actual, string $description = null): void
{
self::$counter++;
if (is_array($actual)) {
if (in_array($needle, $actual, true)) {
self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle);
}
} elseif (is_string($actual)) {
if ($needle === '' || strpos($actual, $needle) !== false) {
self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle);
}
} else {
self::fail(self::describe('%1 should be string or array', $description), $actual);
}
}
/**
* Checks TRUE assertion.
* @param mixed $actual
*/
public static function true($actual, string $description = null): void
{
self::$counter++;
if ($actual !== true) {
self::fail(self::describe('%1 should be TRUE', $description), $actual);
}
}
/**
* Checks FALSE assertion.
* @param mixed $actual
*/
public static function false($actual, string $description = null): void
{
self::$counter++;
if ($actual !== false) {
self::fail(self::describe('%1 should be FALSE', $description), $actual);
}
}
/**
* Checks NULL assertion.
* @param mixed $actual
*/
public static function null($actual, string $description = null): void
{
self::$counter++;
if ($actual !== null) {
self::fail(self::describe('%1 should be NULL', $description), $actual);
}
}
/**
* Checks Not a Number assertion.
* @param mixed $actual
*/
public static function nan($actual, string $description = null): void
{
self::$counter++;
if (!is_float($actual) || !is_nan($actual)) {
self::fail(self::describe('%1 should be NAN', $description), $actual);
}
}
/**
* Checks truthy assertion.
* @param mixed $actual
*/
public static function truthy($actual, string $description = null): void
{
self::$counter++;
if (!$actual) {
self::fail(self::describe('%1 should be truthy', $description), $actual);
}
}
/**
* Checks falsey (empty) assertion.
* @param mixed $actual
*/
public static function falsey($actual, string $description = null): void
{
self::$counter++;
if ($actual) {
self::fail(self::describe('%1 should be falsey', $description), $actual);
}
}
/**
* Checks if subject has expected count.
* @param mixed $value
*/
public static function count(int $count, $value, string $description = null): void
{
self::$counter++;
if (!$value instanceof \Countable && !is_array($value)) {
self::fail(self::describe('%1 should be array or countable object', $description), $value);
} elseif (count($value) !== $count) {
self::fail(self::describe('Count %1 should be %2', $description), count($value), $count);
}
}
/**
* Checks assertion.
* @param string|object $type
* @param mixed $value
*/
public static function type($type, $value, string $description = null): void
{
self::$counter++;
if (!is_object($type) && !is_string($type)) {
throw new \Exception('Type must be a object or a string.');
} elseif ($type === 'list') {
if (!is_array($value) || ($value && array_keys($value) !== range(0, count($value) - 1))) {
self::fail(self::describe("%1 should be $type", $description), $value);
}
} elseif (in_array($type, ['array', 'bool', 'callable', 'float',
'int', 'integer', 'null', 'object', 'resource', 'scalar', 'string', ], true)
) {
if (!("is_$type")($value)) {
self::fail(self::describe(gettype($value) . " should be $type", $description));
}
} elseif (!$value instanceof $type) {
$actual = is_object($value) ? get_class($value) : gettype($value);
self::fail(self::describe("$actual should be instance of $type", $description));
}
}
/**
* Checks if the function throws exception.
*/
public static function exception(callable $function, string $class, string $message = null, $code = null): ?\Throwable
{
self::$counter++;
$e = null;
try {
$function();
} catch (\Throwable $e) {
}
if ($e === null) {
self::fail("$class was expected, but none was thrown");
} elseif (!$e instanceof $class) {
self::fail("$class was expected but got " . get_class($e) . ($e->getMessage() ? " ({$e->getMessage()})" : ''), null, null, $e);
} elseif ($message && !self::isMatching($message, $e->getMessage())) {
self::fail("$class with a message matching %2 was expected but got %1", $e->getMessage(), $message);
} elseif ($code !== null && $e->getCode() !== $code) {
self::fail("$class with a code %2 was expected but got %1", $e->getCode(), $code);
}
return $e;
}
/**
* Checks if the function throws exception, alias for exception().
*/
public static function throws(callable $function, string $class, string $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, $expectedType, string $expectedMessage = null): ?\Throwable
{
if (is_string($expectedType) && !preg_match('#^E_[A-Z_]+\z#', $expectedType)) {
return static::exception($function, $expectedType, $expectedMessage);
}
self::$counter++;
$expected = is_array($expectedType) ? $expectedType : [[$expectedType, $expectedMessage]];
foreach ($expected as &$item) {
$item = ((array) $item) + [null, null];
$expectedType = $item[0];
if (is_int($expectedType)) {
$item[2] = Helpers::errorTypeToString($expectedType);
} elseif (is_string($expectedType)) {
$item[0] = constant($item[2] = $expectedType);
} else {
throw new \Exception('Error type must be E_* constant.');
}
}
set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$expected) {
if (($severity & error_reporting()) !== $severity) {
return;
}
$errorStr = Helpers::errorTypeToString($severity) . ($message ? " ($message)" : '');
[$expectedType, $expectedMessage, $expectedTypeStr] = array_shift($expected);
if ($expectedType === null) {
self::fail("Generated more errors than expected: $errorStr was generated in file $file on line $line");
} elseif ($severity !== $expectedType) {
self::fail("$expectedTypeStr was expected, but $errorStr was generated in file $file on line $line");
} elseif ($expectedMessage && !self::isMatching($expectedMessage, $message)) {
self::fail("$expectedTypeStr with a message matching %2 was expected but got %1", $message, $expectedMessage);
}
});
reset($expected);
try {
$function();
restore_error_handler();
} catch (\Exception $e) {
restore_error_handler();
throw $e;
}
if ($expected) {
self::fail('Error was expected, but was not generated');
}
return null;
}
/**
* 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, $actual, string $description = null): void
{
self::$counter++;
if (!is_scalar($actual)) {
self::fail(self::describe('%1 should match %2', $description), $actual, $pattern);
} elseif (!self::isMatching($pattern, $actual)) {
[$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual);
self::fail(self::describe('%1 should match %2', $description), $actual, $pattern);
}
}
/**
* Compares results using mask sorted in file.
*/
public static function matchFile(string $file, $actual, string $description = null): void
{
self::$counter++;
$pattern = @file_get_contents($file); // @ is escalated to exception
if ($pattern === false) {
throw new \Exception("Unable to read file '$file'.");
} elseif (!is_scalar($actual)) {
self::fail(self::describe('%1 should match %2', $description), $actual, $pattern);
} elseif (!self::isMatching($pattern, $actual)) {
[$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual);
self::fail(self::describe('%1 should match %2', $description), $actual, $pattern);
}
}
/**
* 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 $reason, string $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, $actual, bool $strict = false): bool
{
if (!is_scalar($actual)) {
throw new \Exception('Value must be strings.');
}
$old = ini_set('pcre.backtrack_limit', '10000000');
if (!self::isPcre($pattern)) {
$utf8 = preg_match('#\x80-\x{10FFFF}]#u', $pattern) ? 'u' : '';
$suffix = ($strict ? '\z#sU' : '\s*$#sU') . $utf8;
$patterns = static::$patterns + [
'[.\\\\+*?[^$(){|\#]' => '\$0', // preg quoting
'\x00' => '\x00',
'[\t ]*\r?\n' => '[\t ]*\r?\n', // right trim
];
$pattern = '#^' . preg_replace_callback('#' . implode('|', array_keys($patterns)) . '#U' . $utf8, function ($m) use ($patterns) {
foreach ($patterns as $re => $replacement) {
$s = preg_replace("#^$re\\z#", str_replace('\\', '\\\\', $replacement), $m[0], 1, $count);
if ($count) {
return $s;
}
}
}, rtrim($pattern, " \t\n\r")) . $suffix;
}
$res = preg_match($pattern, (string) $actual);
ini_set('pcre.backtrack_limit', $old);
if ($res === false || preg_last_error()) {
throw new \Exception('Error while executing regular expression. (PREG Error Code ' . preg_last_error() . ')');
}
return (bool) $res;
}
/**
* @internal
*/
public static function expandMatchingPatterns(string $pattern, $actual): array
{
if (self::isPcre($pattern)) {
return [$pattern, $actual];
}
$parts = preg_split('#(%)#', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE);
for ($i = count($parts); $i >= 0; $i--) {
$patternX = implode(array_slice($parts, 0, $i));
$patternY = "$patternX%A?%";
if (self::isMatching($patternY, $actual)) {
$patternZ = implode(array_slice($parts, $i));
break;
}
}
foreach (['%A%', '%A?%'] as $greedyPattern) {
if (substr($patternX, -strlen($greedyPattern)) === $greedyPattern) {
$patternX = substr($patternX, 0, -strlen($greedyPattern));
$patternY = "$patternX%A?%";
$patternZ = $greedyPattern . $patternZ;
break;
}
}
$low = 0;
$high = strlen($actual);
while ($low <= $high) {
$mid = ($low + $high) >> 1;
if (self::isMatching($patternY, substr($actual, 0, $mid))) {
$high = $mid - 1;
} else {
$low = $mid + 1;
}
}
$low = $high + 2;
$high = strlen($actual);
while ($low <= $high) {
$mid = ($low + $high) >> 1;
if (!self::isMatching($patternX, substr($actual, 0, $mid), true)) {
$high = $mid - 1;
} else {
$low = $mid + 1;
}
}
$actualX = substr($actual, 0, $high);
$actualZ = substr($actual, $high);
return [
$actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $patternZ)),
$actualX . rtrim(preg_replace('#[\t ]*\r?\n#', "\n", $actualZ)),
];
}
/**
* Compares two structures. Ignores the identity of objects and the order of keys in the arrays.
*/
private static function isEqual($expected, $actual, int $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($expected, SORT_STRING);
ksort($actual, SORT_STRING);
if (array_keys($expected) !== array_keys($actual)) {
return false;
}
foreach ($expected as $value) {
if (!self::isEqual($value, current($actual), $level + 1, $objects)) {
return false;
}
next($actual);
}
return true;
}
return $expected === $actual;
}
private static function isPcre(string $pattern): bool
{
return (bool) preg_match('/^([~#]).+(\1)[imsxUu]*\z/s', $pattern);
}
}
<?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;
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester;
/**
* Data provider helpers.
*/
class DataProvider
{
/**
* @throws \Exception
*/
public static function load(string $file, string $query = ''): array
{
if (!is_file($file)) {
throw new \Exception("Missing data-provider file '$file'.");
}
if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
$data = (function () {
return require func_get_arg(0);
})(realpath($file));
if ($data instanceof \Traversable) {
$data = iterator_to_array($data);
} elseif (!is_array($data)) {
throw new \Exception("Data provider file '$file' did not return array or Traversable.");
}
} else {
$data = @parse_ini_file($file, true); // @ is escalated to exception
if ($data === false) {
throw new \Exception("Cannot parse data-provider file '$file'.");
}
}
foreach ($data as $key => $value) {
if (!self::testQuery($key, $query)) {
unset($data[$key]);
}
}
if (!$data) {
throw new \Exception("No records in data-provider file '$file'" . ($query ? " for query '$query'" : '') . '.');
}
return $data;
}
public static function testQuery(string $input, string $query): bool
{
static $replaces = ['' => '=', '=>' => '>=', '=<' => '<='];
$tokens = preg_split('#\s+#', $input);
preg_match_all('#\s*,?\s*(<=|=<|<|==|=|!=|<>|>=|=>|>)?\s*([^\s,]+)#A', $query, $queryParts, PREG_SET_ORDER);
foreach ($queryParts as [, $operator, $operand]) {
$operator = $replaces[$operator] ?? $operator;
$token = (string) array_shift($tokens);
$res = preg_match('#^[0-9.]+\z#', $token)
? version_compare($token, $operand, $operator)
: self::compare($token, $operator, $operand);
if (!$res) {
return false;
}
}
return true;
}
private static function compare($l, string $operator, $r): bool
{
switch ($operator) {
case '>':
return $l > $r;
case '=>':
case '>=':
return $l >= $r;
case '<':
return $l < $r;
case '=<':
case '<=':
return $l <= $r;
case '=':
case '==':
return $l == $r;
case '!':
case '!=':
case '<>':
return $l != $r;
}
throw new \InvalidArgumentException("Unknown operator $operator.");
}
/**
* @internal
* @throws \Exception
*/
public static function parseAnnotation(string $annotation, string $file): array
{
if (!preg_match('#^(\??)\s*([^,\s]+)\s*,?\s*(\S.*)?()#', $annotation, $m)) {
throw new \Exception("Invalid @dataProvider value '$annotation'.");
}
return [dirname($file) . DIRECTORY_SEPARATOR . $m[2], $m[3], (bool) $m[1]];
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester;
/**
* DomQuery simplifies querying (X)HTML documents.
*/
class DomQuery extends \SimpleXMLElement
{
public static function fromHtml(string $html): self
{
if (strpos($html, '<') === false) {
$html = '<body>' . $html;
}
// parse these elements as void
$html = preg_replace('#<(keygen|source|track|wbr)(?=\s|>)((?:"[^"]*"|\'[^\']*\'|[^"\'>])*+)(?<!/)>#', '<$1$2 />', $html);
// fix parsing of </ inside scripts
$html = preg_replace_callback('#(<script(?=\s|>)(?:"[^"]*"|\'[^\']*\'|[^"\'>])*+>)(.*?)(</script>)#s', function (array $m): string {
return $m[1] . str_replace('</', '<\/', $m[2]) . $m[3];
}, $html);
$dom = new \DOMDocument();
$old = libxml_use_internal_errors(true);
libxml_clear_errors();
$dom->loadHTML($html);
$errors = libxml_get_errors();
libxml_use_internal_errors($old);
$re = '#Tag (article|aside|audio|bdi|canvas|data|datalist|figcaption|figure|footer|header|keygen|main|mark'
. '|meter|nav|output|picture|progress|rb|rp|rt|rtc|ruby|section|source|template|time|track|video|wbr) invalid#';
foreach ($errors as $error) {
if (!preg_match($re, $error->message)) {
trigger_error(__METHOD__ . ": $error->message on line $error->line.", E_USER_WARNING);
}
}
return simplexml_import_dom($dom, __CLASS__);
}
public static function fromXml(string $xml): self
{
return simplexml_load_string($xml, __CLASS__);
}
/**
* Returns array of descendants filtered by a selector.
* @return DomQuery[]
*/
public function find(string $selector): array
{
return $this->xpath(self::css2xpath($selector));
}
/**
* Check the current document against a selector.
*/
public function has(string $selector): bool
{
return (bool) $this->find($selector);
}
/**
* Transforms CSS expression to XPath.
*/
public static function css2xpath(string $css): string
{
$xpath = '//*';
preg_match_all('/
([#.:]?)([a-z][a-z0-9_-]*)| # id, class, pseudoclass (1,2)
\[
([a-z0-9_-]+)
(?:
([~*^$]?)=(
"[^"]*"|
\'[^\']*\'|
[^\]]+
)
)?
\]| # [attr=val] (3,4,5)
\s*([>,+~])\s*| # > , + ~ (6)
(\s+)| # whitespace (7)
(\*) # * (8)
/ix', trim($css), $matches, PREG_SET_ORDER);
foreach ($matches as $m) {
if ($m[1] === '#') { // #ID
$xpath .= "[@id='$m[2]']";
} elseif ($m[1] === '.') { // .class
$xpath .= "[contains(concat(' ', normalize-space(@class), ' '), ' $m[2] ')]";
} elseif ($m[1] === ':') { // :pseudo-class
throw new \InvalidArgumentException('Not implemented.');
} elseif ($m[2]) { // tag
$xpath = rtrim($xpath, '*') . $m[2];
} elseif ($m[3]) { // [attribute]
$attr = '@' . strtolower($m[3]);
if (!isset($m[5])) {
$xpath .= "[$attr]";
continue;
}
$val = trim($m[5], '"\'');
if ($m[4] === '') {
$xpath .= "[$attr='$val']";
} elseif ($m[4] === '~') {
$xpath .= "[contains(concat(' ', normalize-space($attr), ' '), ' $val ')]";
} elseif ($m[4] === '*') {
$xpath .= "[contains($attr, '$val')]";
} elseif ($m[4] === '^') {
$xpath .= "[starts-with($attr, '$val')]";
} elseif ($m[4] === '$') {
$xpath .= "[substring($attr, string-length($attr)-0)='$val']";
}
} elseif ($m[6] === '>') {
$xpath .= '/*';
} elseif ($m[6] === ',') {
$xpath .= '|//*';
} elseif ($m[6] === '~') {
$xpath .= '/following-sibling::*';
} elseif ($m[6] === '+') {
throw new \InvalidArgumentException('Not implemented.');
} elseif ($m[7]) {
$xpath .= '//*';
}
}
return $xpath;
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester;
/**
* Dumps PHP variables.
*/
class Dumper
{
public static $maxLength = 70;
public static $maxDepth = 10;
public static $dumpDir = 'output';
public static $maxPathSegments = 3;
/**
* Dumps information about a variable in readable format.
* @param mixed $var variable to dump
*/
public static function toLine($var): string
{
static $table;
if ($table === null) {
foreach (array_merge(range("\x00", "\x1F"), range("\x7F", "\xFF")) as $ch) {
$table[$ch] = '\x' . str_pad(dechex(ord($ch)), 2, '0', STR_PAD_LEFT);
}
$table['\\'] = '\\\\';
$table["\r"] = '\r';
$table["\n"] = '\n';
$table["\t"] = '\t';
}
if (is_bool($var)) {
return $var ? 'TRUE' : 'FALSE';
} elseif ($var === null) {
return 'NULL';
} elseif (is_int($var)) {
return "$var";
} elseif (is_float($var)) {
return var_export($var, true);
} elseif (is_string($var)) {
if (preg_match('#^(.{' . self::$maxLength . '}).#su', $var, $m)) {
$var = "$m[1]...";
} elseif (strlen($var) > self::$maxLength) {
$var = substr($var, 0, self::$maxLength) . '...';
}
return preg_match('#[^\x09\x0A\x0D\x20-\x7E\xA0-\x{10FFFF}]#u', $var) || preg_last_error() ? '"' . strtr($var, $table) . '"' : "'$var'";
} elseif (is_array($var)) {
$out = '';
$counter = 0;
foreach ($var as $k => &$v) {
$out .= ($out === '' ? '' : ', ');
if (strlen($out) > self::$maxLength) {
$out .= '...';
break;
}
$out .= ($k === $counter ? '' : self::toLine($k) . ' => ')
. (is_array($v) && $v ? '[...]' : self::toLine($v));
$counter = is_int($k) ? max($k + 1, $counter) : $counter;
}
return "[$out]";
} elseif ($var instanceof \Throwable) {
return 'Exception ' . get_class($var) . ': ' . ($var->getCode() ? '#' . $var->getCode() . ' ' : '') . $var->getMessage();
} elseif (is_object($var)) {
return self::objectToLine($var);
} elseif (is_resource($var)) {
return 'resource(' . get_resource_type($var) . ')';
} else {
return 'unknown type';
}
}
/**
* Formats object to line.
* @param object $object
*/
private static function objectToLine($object): string
{
$line = get_class($object);
if ($object instanceof \DateTime || $object instanceof \DateTimeInterface) {
$line .= '(' . $object->format('Y-m-d H:i:s O') . ')';
}
return $line . '(' . self::hash($object) . ')';
}
/**
* Dumps variable in PHP format.
* @param mixed $var variable to dump
*/
public static function toPhp($var): string
{
return self::_toPhp($var);
}
/**
* Returns object's stripped hash.
* @param object $object
*/
private static function hash($object): string
{
return '#' . substr(md5(spl_object_hash($object)), 0, 4);
}
private static function _toPhp(&$var, array &$list = [], int $level = 0, int &$line = 1): string
{
if (is_float($var)) {
$var = str_replace(',', '.', "$var");
return strpos($var, '.') === false ? $var . '.0' : $var;
} elseif (is_bool($var)) {
return $var ? 'true' : 'false';
} elseif ($var === null) {
return 'null';
} elseif (is_string($var) && (preg_match('#[^\x09\x20-\x7E\xA0-\x{10FFFF}]#u', $var) || preg_last_error())) {
static $table;
if ($table === null) {
foreach (array_merge(range("\x00", "\x1F"), range("\x7F", "\xFF")) as $ch) {
$table[$ch] = '\x' . str_pad(dechex(ord($ch)), 2, '0', STR_PAD_LEFT);
}
$table['\\'] = '\\\\';
$table["\r"] = '\r';
$table["\n"] = '\n';
$table["\t"] = '\t';
$table['$'] = '\$';
$table['"'] = '\"';
}
return '"' . strtr($var, $table) . '"';
} elseif (is_array($var)) {
$space = str_repeat("\t", $level);
static $marker;
if ($marker === null) {
$marker = uniqid("\x00", true);
}
if (empty($var)) {
$out = '';
} elseif ($level > self::$maxDepth || isset($var[$marker])) {
return '/* Nesting level too deep or recursive dependency */';
} else {
$out = "\n$space";
$outShort = '';
$var[$marker] = true;
$oldLine = $line;
$line++;
$counter = 0;
foreach ($var as $k => &$v) {
if ($k !== $marker) {
$item = ($k === $counter ? '' : self::_toPhp($k, $list, $level + 1, $line) . ' => ') . self::_toPhp($v, $list, $level + 1, $line);
$counter = is_int($k) ? max($k + 1, $counter) : $counter;
$outShort .= ($outShort === '' ? '' : ', ') . $item;
$out .= "\t$item,\n$space";
$line++;
}
}
unset($var[$marker]);
if (strpos($outShort, "\n") === false && strlen($outShort) < self::$maxLength) {
$line = $oldLine;
$out = $outShort;
}
}
return '[' . $out . ']';
} elseif ($var instanceof \Closure) {
$rc = new \ReflectionFunction($var);
return "/* Closure defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */";
} elseif (is_object($var)) {
if (($rc = new \ReflectionObject($var))->isAnonymous()) {
return "/* Anonymous class defined in file {$rc->getFileName()} on line {$rc->getStartLine()} */";
}
$arr = (array) $var;
$space = str_repeat("\t", $level);
$class = get_class($var);
$used = &$list[spl_object_hash($var)];
if (empty($arr)) {
$out = '';
} elseif ($used) {
return "/* $class dumped on line $used */";
} elseif ($level > self::$maxDepth) {
return '/* Nesting level too deep */';
} else {
$out = "\n";
$used = $line;
$line++;
foreach ($arr as $k => &$v) {
if ($k[0] === "\x00") {
$k = substr($k, strrpos($k, "\x00") + 1);
}
$out .= "$space\t" . self::_toPhp($k, $list, $level + 1, $line) . ' => ' . self::_toPhp($v, $list, $level + 1, $line) . ",\n";
$line++;
}
$out .= $space;
}
$hash = self::hash($var);
return $class === 'stdClass'
? "(object) /* $hash */ [$out]"
: "$class::__set_state(/* $hash */ [$out])";
} elseif (is_resource($var)) {
return '/* resource ' . get_resource_type($var) . ' */';
} else {
$res = var_export($var, true);
$line += substr_count($res, "\n");
return $res;
}
}
/**
* @internal
*/
public static function dumpException(\Throwable $e): string
{
$trace = $e->getTrace();
array_splice($trace, 0, $e instanceof \ErrorException ? 1 : 0, [['file' => $e->getFile(), 'line' => $e->getLine()]]);
$testFile = null;
foreach (array_reverse($trace) as $item) {
if (isset($item['file'])) { // in case of shutdown handler, we want to skip inner-code blocks and debugging calls
$testFile = $item['file'];
break;
}
}
if ($e instanceof AssertException) {
$expected = $e->expected;
$actual = $e->actual;
if (is_object($expected) || is_array($expected) || (is_string($expected) && strlen($expected) > self::$maxLength)
|| is_object($actual) || is_array($actual) || (is_string($actual) && strlen($actual) > self::$maxLength)
) {
$args = isset($_SERVER['argv'][1])
? '.[' . implode(' ', preg_replace(['#^-*(.{1,20}).*#i', '#[^=a-z0-9. -]+#i'], ['$1', '-'], array_slice($_SERVER['argv'], 1))) . ']'
: '';
$stored[] = self::saveOutput($testFile, $expected, $args . '.expected');
$stored[] = self::saveOutput($testFile, $actual, $args . '.actual');
}
if ((is_string($actual) && is_string($expected))) {
for ($i = 0; $i < strlen($actual) && isset($expected[$i]) && $actual[$i] === $expected[$i]; $i++);
for (; $i && $i < strlen($actual) && $actual[$i - 1] >= "\x80" && $actual[$i] >= "\x80" && $actual[$i] < "\xC0"; $i--);
$i = max(0, min(
$i - (int) (self::$maxLength / 3), // try to display 1/3 of shorter string
max(strlen($actual), strlen($expected)) - self::$maxLength + 3 // 3 = length of ...
));
if ($i) {
$expected = substr_replace($expected, '...', 0, $i);
$actual = substr_replace($actual, '...', 0, $i);
}
}
$message = 'Failed: ' . $e->origMessage;
if (((is_string($actual) && is_string($expected)) || (is_array($actual) && is_array($expected)))
&& preg_match('#^(.*)(%\d)(.*)(%\d.*)\z#s', $message, $m)
) {
if (($delta = strlen($m[1]) - strlen($m[3])) >= 3) {
$message = "$m[1]$m[2]\n" . str_repeat(' ', $delta - 3) . "...$m[3]$m[4]";
} else {
$message = "$m[1]$m[2]$m[3]\n" . str_repeat(' ', strlen($m[1]) - 4) . "... $m[4]";
}
}
$message = strtr($message, [
'%1' => self::color('yellow') . self::toLine($actual) . self::color('white'),
'%2' => self::color('yellow') . self::toLine($expected) . self::color('white'),
]);
} else {
$message = ($e instanceof \ErrorException ? Helpers::errorTypeToString($e->getSeverity()) : get_class($e))
. ': ' . preg_replace('#[\x00-\x09\x0B-\x1F]+#', ' ', $e->getMessage());
}
$s = self::color('white', $message) . "\n\n"
. (isset($stored) ? 'diff ' . Helpers::escapeArg($stored[0]) . ' ' . Helpers::escapeArg($stored[1]) . "\n\n" : '');
foreach ($trace as $item) {
$item += ['file' => null, 'class' => null, 'type' => null, 'function' => null];
if ($e instanceof AssertException && $item['file'] === __DIR__ . DIRECTORY_SEPARATOR . 'Assert.php') {
continue;
}
$line = $item['class'] === '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_SEPARATOR, array_slice(explode(DIRECTORY_SEPARATOR, $item['file']), -self::$maxPathSegments))
. "($item[line])" . self::color('gray') . ' '
)
: '[internal function]'
)
. ($line
? trim($line)
: $item['class'] . $item['type'] . $item['function'] . ($item['function'] ? '()' : '')
)
. self::color() . "\n";
}
if ($e->getPrevious()) {
$s .= "\n(previous) " . static::dumpException($e->getPrevious());
}
return $s;
}
/**
* Dumps data to folder 'output'.
* @internal
*/
public static function saveOutput(string $testFile, $content, string $suffix = ''): string
{
$path = self::$dumpDir . DIRECTORY_SEPARATOR . pathinfo($testFile, PATHINFO_FILENAME) . $suffix;
if (!preg_match('#/|\w:#A', self::$dumpDir)) {
$path = dirname($testFile) . DIRECTORY_SEPARATOR . $path;
}
@mkdir(dirname($path)); // @ - directory may already exist
file_put_contents($path, is_string($content) ? $content : (self::toPhp($content) . "\n"));
return $path;
}
/**
* Applies color to string.
*/
public static function color(string $color = '', string $s = null): string
{
static $colors = [
'black' => '0;30', 'gray' => '1;30', 'silver' => '0;37', 'white' => '1;37',
'navy' => '0;34', 'blue' => '1;34', 'green' => '0;32', 'lime' => '1;32',
'teal' => '0;36', 'aqua' => '1;36', 'maroon' => '0;31', 'red' => '1;31',
'purple' => '0;35', 'fuchsia' => '1;35', 'olive' => '0;33', 'yellow' => '1;33',
null => '0',
];
$c = explode('/', $color);
return "\e["
. str_replace(';', "m\e[", $colors[$c[0]] . (empty($c[1]) ? '' : ';4' . substr($colors[$c[1]], -1)))
. 'm' . $s . ($s === null ? '' : "\e[0m");
}
public static function removeColors(string $s): string
{
return preg_replace('#\e\[[\d;]+m#', '', $s);
}
}
<?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);
}, 1, PHP_OUTPUT_HANDLER_FLUSHABLE);
}
/**
* Configures PHP error handling.
*/
public static function setupErrors(): void
{
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('html_errors', '0');
ini_set('log_errors', '0');
set_exception_handler([__CLASS__, 'handleException']);
set_error_handler(function (int $severity, string $message, string $file, int $line): ?bool {
if (in_array($severity, [E_RECOVERABLE_ERROR, E_USER_ERROR], true) || ($severity & error_reporting()) === $severity) {
self::handleException(new \ErrorException($message, 0, $severity, $file, $line));
}
return false;
});
register_shutdown_function(function (): void {
Assert::$onFailure = [__CLASS__, 'handleException'];
$error = error_get_last();
register_shutdown_function(function () use ($error): void {
if (in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) {
if (($error['type'] & error_reporting()) !== $error['type']) { // show fatal errors hidden by @shutup
self::removeOutputBuffers();
echo "\nFatal error: $error[message] in $error[file] on line $error[line]\n";
}
} elseif (self::$checkAssertions && !Assert::$counter) {
self::removeOutputBuffers();
echo "\nError: This test forgets to execute an assertion.\n";
exit(Runner\Job::CODE_FAIL);
}
});
});
}
/**
* @internal
*/
public static function handleException(\Throwable $e): void
{
self::removeOutputBuffers();
self::$checkAssertions = false;
echo Dumper::dumpException($e);
exit($e instanceof AssertException ? Runner\Job::CODE_FAIL : Runner\Job::CODE_ERROR);
}
/**
* Skips this test.
*/
public static function skip(string $message = ''): void
{
self::$checkAssertions = false;
echo "\nSkipped:\n$message\n";
die(Runner\Job::CODE_SKIP);
}
/**
* Locks the parallel tests.
* @param string $path lock store directory
*/
public static function lock(string $name = '', string $path = ''): void
{
static $locks;
$file = "$path/lock-" . md5($name);
if (!isset($locks[$file])) {
flock($locks[$file] = fopen($file, 'w'), LOCK_EX);
}
}
/**
* Returns current test annotations.
*/
public static function getTestAnnotations(): array
{
$trace = debug_backtrace();
$file = $trace[count($trace) - 1]['file'];
return Helpers::parseDocComment(file_get_contents($file)) + ['file' => $file];
}
/**
* Removes keyword final from source codes.
*/
public static function bypassFinals(): void
{
FileMutator::addMutator(function (string $code): string {
if (strpos($code, 'final') !== false) {
$tokens = token_get_all($code, TOKEN_PARSE);
$code = '';
foreach ($tokens as $token) {
$code .= is_array($token)
? ($token[0] === T_FINAL ? '' : $token[1])
: $token;
}
}
return $code;
});
}
/**
* Loads data according to the file annotation or specified by Tester\Runner\TestHandler::initiateDataProvider()
*/
public static function loadData(): array
{
if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--dataprovider=(.*)#Ai', '$1', $_SERVER['argv']))) {
[$query, $file] = explode('|', reset($tmp), 2);
} else {
$annotations = self::getTestAnnotations();
if (!isset($annotations['dataprovider'])) {
throw new \Exception('Missing annotation @dataProvider.');
}
$provider = (array) $annotations['dataprovider'];
[$file, $query] = DataProvider::parseAnnotation($provider[0], $annotations['file']);
}
$data = DataProvider::load($file, $query);
return reset($data);
}
private static function removeOutputBuffers(): void
{
while (ob_get_level() > self::$obLevel && @ob_end_flush()); // @ may be not removable
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester;
/**
* Mock files.
*/
class FileMock
{
private const PROTOCOL = 'mock';
/** @var string[] */
public static $files = [];
/** @var string */
private $content;
/** @var int */
private $readingPos;
/** @var int */
private $writingPos;
/** @var bool */
private $appendMode;
/** @var bool */
private $isReadable;
/** @var bool */
private $isWritable;
/**
* @return string file name
*/
public static function create(string $content = '', string $extension = null): string
{
self::register();
static $id;
$name = self::PROTOCOL . '://' . (++$id) . '.' . $extension;
self::$files[$name] = $content;
return $name;
}
public static function register(): void
{
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
stream_wrapper_register(self::PROTOCOL, __CLASS__);
}
}
public function stream_open(string $path, string $mode): bool
{
if (!preg_match('#^([rwaxc]).*?(\+)?#', $mode, $m)) {
// Windows: failed to open stream: Bad file descriptor
// Linux: failed to open stream: Illegal seek
$this->warning("failed to open stream: Invalid mode '$mode'");
return false;
} elseif ($m[1] === 'x' && isset(self::$files[$path])) {
$this->warning('failed to open stream: File exists');
return false;
} elseif ($m[1] === 'r' && !isset(self::$files[$path])) {
$this->warning('failed to open stream: No such file or directory');
return false;
} elseif ($m[1] === 'w' || $m[1] === 'x') {
self::$files[$path] = '';
}
$this->content = &self::$files[$path];
$this->content = (string) $this->content;
$this->appendMode = $m[1] === 'a';
$this->readingPos = 0;
$this->writingPos = $this->appendMode ? strlen($this->content) : 0;
$this->isReadable = isset($m[2]) || $m[1] === 'r';
$this->isWritable = isset($m[2]) || $m[1] !== 'r';
return true;
}
public function stream_read(int $length): string
{
if (!$this->isReadable) {
return '';
}
$result = substr($this->content, $this->readingPos, $length);
$this->readingPos += strlen($result);
$this->writingPos += $this->appendMode ? 0 : strlen($result);
return $result;
}
public function stream_write(string $data): int
{
if (!$this->isWritable) {
return 0;
}
$length = strlen($data);
$this->content = str_pad($this->content, $this->writingPos, "\x00");
$this->content = substr_replace($this->content, $data, $this->writingPos, $length);
$this->readingPos += $length;
$this->writingPos += $length;
return $length;
}
public function stream_tell(): int
{
return $this->readingPos;
}
public function stream_eof(): bool
{
return $this->readingPos >= strlen($this->content);
}
public function stream_seek(int $offset, int $whence): bool
{
if ($whence === SEEK_CUR) {
$offset += $this->readingPos;
} elseif ($whence === SEEK_END) {
$offset += strlen($this->content);
}
if ($offset >= 0) {
$this->readingPos = $offset;
$this->writingPos = $this->appendMode ? $this->writingPos : $offset;
return true;
} else {
return false;
}
}
public function stream_truncate(int $size): bool
{
if (!$this->isWritable) {
return false;
}
$this->content = substr(str_pad($this->content, $size, "\x00"), 0, $size);
$this->writingPos = $this->appendMode ? $size : $this->writingPos;
return true;
}
public function stream_stat(): array
{
return ['mode' => 0100666, 'size' => strlen($this->content)];
}
public function url_stat(string $path, int $flags)
{
return isset(self::$files[$path])
? ['mode' => 0100666, 'size' => strlen(self::$files[$path])]
: false;
}
public function stream_lock(int $operation): bool
{
return false;
}
public function unlink(string $path): bool
{
if (isset(self::$files[$path])) {
unset(self::$files[$path]);
return true;
}
$this->warning('No such file');
return false;
}
private function warning(string $message): void
{
$bt = debug_backtrace(0, 3);
if (isset($bt[2]['function'])) {
$message = $bt[2]['function'] . '(' . @$bt[2]['args'][0] . '): ' . $message;
}
trigger_error($message, E_USER_WARNING);
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester;
/**
* PHP file mutator.
*/
class FileMutator
{
private const PROTOCOL = 'file';
/** @var resource|null */
public $context;
/** @var resource|null */
private $handle;
/** @var callable[] */
private static $mutators = [];
public static function addMutator(callable $mutator): void
{
self::$mutators[] = $mutator;
stream_wrapper_unregister(self::PROTOCOL);
stream_wrapper_register(self::PROTOCOL, __CLASS__);
}
public function dir_closedir(): void
{
closedir($this->handle);
}
public function dir_opendir(string $path, int $options): bool
{
$this->handle = $this->context
? $this->native('opendir', $path, $this->context)
: $this->native('opendir', $path);
return (bool) $this->handle;
}
public function dir_readdir()
{
return readdir($this->handle);
}
public function dir_rewinddir(): bool
{
return (bool) rewinddir($this->handle);
}
public function mkdir(string $path, int $mode, int $options): bool
{
return $this->native('mkdir', $path, $mode, false, $this->context);
}
public function rename(string $pathFrom, string $pathTo): bool
{
return $this->native('rename', $pathFrom, $pathTo, $this->context);
}
public function rmdir(string $path, int $options): bool
{
return $this->native('rmdir', $path, $this->context);
}
public function stream_cast(int $castAs)
{
return $this->handle;
}
public function stream_close(): void
{
fclose($this->handle);
}
public function stream_eof(): bool
{
return feof($this->handle);
}
public function stream_flush(): bool
{
return fflush($this->handle);
}
public function stream_lock(int $operation): bool
{
return flock($this->handle, $operation);
}
public function stream_metadata(string $path, int $option, $value): bool
{
switch ($option) {
case STREAM_META_TOUCH:
$value += [null, null];
return $this->native('touch', $path, $value[0], $value[1]);
case STREAM_META_OWNER_NAME:
case STREAM_META_OWNER:
return $this->native('chown', $path, $value);
case STREAM_META_GROUP_NAME:
case STREAM_META_GROUP:
return $this->native('chgrp', $path, $value);
case STREAM_META_ACCESS:
return $this->native('chmod', $path, $value);
}
return false;
}
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
{
$usePath = (bool) ($options & STREAM_USE_PATH);
if (pathinfo($path, PATHINFO_EXTENSION) === 'php') {
$content = $this->native('file_get_contents', $path, $usePath, $this->context);
if ($content === false) {
return false;
} else {
foreach (self::$mutators as $mutator) {
$content = $mutator($content);
}
$this->handle = tmpfile();
$this->native('fwrite', $this->handle, $content);
$this->native('fseek', $this->handle, 0);
return true;
}
} else {
$this->handle = $this->context
? $this->native('fopen', $path, $mode, $usePath, $this->context)
: $this->native('fopen', $path, $mode, $usePath);
return (bool) $this->handle;
}
}
public function stream_read(int $count)
{
return fread($this->handle, $count);
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
return fseek($this->handle, $offset, $whence) === 0;
}
public function stream_set_option(int $option, int $arg1, int $arg2)
{
}
public function stream_stat()
{
return fstat($this->handle);
}
public function stream_tell(): int
{
return ftell($this->handle);
}
public function stream_truncate(int $newSize): bool
{
return ftruncate($this->handle, $newSize);
}
public function stream_write(string $data): int
{
return fwrite($this->handle, $data);
}
public function unlink(string $path): bool
{
return $this->native('unlink', $path);
}
public function url_stat(string $path, int $flags)
{
$func = $flags & STREAM_URL_STAT_LINK ? 'lstat' : 'stat';
return $flags & STREAM_URL_STAT_QUIET
? @$this->native($func, $path)
: $this->native($func, $path);
}
private function native(string $func)
{
stream_wrapper_restore(self::PROTOCOL);
$res = $func(...array_slice(func_get_args(), 1));
stream_wrapper_unregister(self::PROTOCOL);
stream_wrapper_register(self::PROTOCOL, __CLASS__);
return $res;
}
}
<?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], $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$ref = &$options[strtolower($match[1])];
if (isset($ref)) {
$ref = (array) $ref;
$ref = &$ref[];
}
$ref = isset($match[2]) ? trim($match[2]) : '';
}
return $options;
}
/**
* @internal
*/
public static function errorTypeToString(int $type): string
{
$consts = get_defined_constants(true);
foreach ($consts['Core'] as $name => $val) {
if ($type === $val && substr($name, 0, 2) === 'E_') {
return $name;
}
}
}
/**
* Escape a string to be used as a shell argument.
*/
public static function escapeArg(string $s): string
{
if (preg_match('#^[a-z0-9._=/:-]+\z#i', $s)) {
return $s;
}
return defined('PHP_WINDOWS_VERSION_BUILD')
? '"' . str_replace('"', '""', $s) . '"'
: escapeshellarg($s);
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester;
/**
* Single test case.
*/
class TestCase
{
/** @internal */
public const
LIST_METHODS = 'nette-tester-list-methods',
METHOD_PATTERN = '#^test[A-Z0-9_]#';
/** @var bool */
private $handleErrors = false;
/** @var callable|null|false */
private $prevErrorHandler = false;
/**
* Runs the test case.
*/
public function run(): void
{
if (func_num_args()) {
throw new \LogicException('Calling TestCase::run($method) is deprecated. Use TestCase::runTest($method) instead.');
}
$methods = array_values(preg_grep(self::METHOD_PATTERN, array_map(function (\ReflectionMethod $rm): string {
return $rm->getName();
}, (new \ReflectionObject($this))->getMethods())));
if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--method=([\w-]+)$#Ai', '$1', $_SERVER['argv']))) {
$method = reset($tmp);
if ($method === self::LIST_METHODS) {
Environment::$checkAssertions = false;
header('Content-Type: text/plain');
echo '[' . implode(',', $methods) . ']';
return;
}
$this->runTest($method);
} else {
foreach ($methods as $method) {
$this->runTest($method);
}
}
}
/**
* Runs the test method.
* @param array $args test method parameters (dataprovider bypass)
*/
public function runTest(string $method, array $args = null): void
{
if (!method_exists($this, $method)) {
throw new TestCaseException("Method '$method' does not exist.");
} elseif (!preg_match(self::METHOD_PATTERN, $method)) {
throw new TestCaseException("Method '$method' is not a testing method.");
}
$method = new \ReflectionMethod($this, $method);
if (!$method->isPublic()) {
throw new TestCaseException("Method {$method->getName()} is not public. Make it public or rename it.");
}
$info = Helpers::parseDocComment((string) $method->getDocComment()) + ['dataprovider' => null, 'throws' => null];
if ($info['throws'] === '') {
throw new TestCaseException("Missing class name in @throws annotation for {$method->getName()}().");
} elseif (is_array($info['throws'])) {
throw new TestCaseException("Annotation @throws for {$method->getName()}() can be specified only once.");
} else {
$throws = is_string($info['throws']) ? preg_split('#\s+#', $info['throws'], 2) : [];
}
$data = [];
if ($args === null) {
$defaultParams = [];
foreach ($method->getParameters() as $param) {
$defaultParams[$param->getName()] = $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null;
}
foreach ((array) $info['dataprovider'] as $provider) {
$res = $this->getData($provider);
if (!is_array($res) && !$res instanceof \Traversable) {
throw new TestCaseException("Data provider $provider() doesn't return array or Traversable.");
}
foreach ($res as $set) {
$data[] = is_string(key($set)) ? array_merge($defaultParams, $set) : $set;
}
}
if (!$info['dataprovider']) {
if ($method->getNumberOfRequiredParameters()) {
throw new TestCaseException("Method {$method->getName()}() has arguments, but @dataProvider is missing.");
}
$data[] = [];
}
} else {
$data[] = $args;
}
if ($this->prevErrorHandler === false) {
$this->prevErrorHandler = set_error_handler(function (int $severity): ?bool {
if ($this->handleErrors && ($severity & error_reporting()) === $severity) {
$this->handleErrors = false;
$this->silentTearDown();
}
return $this->prevErrorHandler ? ($this->prevErrorHandler)(...func_get_args()) : false;
});
}
foreach ($data as $params) {
try {
$this->setUp();
$this->handleErrors = true;
$params = array_values($params);
try {
if ($info['throws']) {
$e = Assert::error(function () use ($method, $params): void {
[$this, $method->getName()](...$params);
}, ...$throws);
if ($e instanceof AssertException) {
throw $e;
}
} else {
[$this, $method->getName()](...$params);
}
} catch (\Exception $e) {
$this->handleErrors = false;
$this->silentTearDown();
throw $e;
}
$this->handleErrors = false;
$this->tearDown();
} catch (AssertException $e) {
throw $e->setMessage("$e->origMessage in {$method->getName()}(" . (substr(Dumper::toLine($params), 1, -1)) . ')');
}
}
}
/**
* @return mixed
*/
protected function getData(string $provider)
{
if (strpos($provider, '.') === false) {
return $this->$provider();
} else {
$rc = new \ReflectionClass($this);
[$file, $query] = DataProvider::parseAnnotation($provider, $rc->getFileName());
return DataProvider::load($file, $query);
}
}
/**
* This method is called before a test is executed.
* @return void
*/
protected function setUp()
{
}
/**
* This method is called after a test is executed.
* @return void
*/
protected function tearDown()
{
}
private function silentTearDown(): void
{
set_error_handler(function () {});
try {
$this->tearDown();
} catch (\Exception $e) {
}
restore_error_handler();
}
}
class TestCaseException extends \Exception
{
}
<?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 ? 0 : 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 => true, CommandLine::REALPATH => true],
'--setup' => [CommandLine::REALPATH => true],
'--temp' => [CommandLine::REALPATH => true],
'paths' => [CommandLine::REPEATABLE => true, CommandLine::VALUE => getcwd()],
'--debug' => [],
'--coverage-src' => [CommandLine::REALPATH => true, CommandLine::REPEATABLE => true],
]);
if (isset($_SERVER['argv'])) {
if ($tmp = array_search('-log', $_SERVER['argv'], true)) {
$_SERVER['argv'][$tmp] = '--log';
}
if ($tmp = array_search('--tap', $_SERVER['argv'], true)) {
unset($_SERVER['argv'][$tmp]);
$_SERVER['argv'] = array_merge($_SERVER['argv'], ['-o', 'tap']);
}
}
$this->options = $cmd->parse();
if ($this->options['--temp'] === null) {
if (($temp = sys_get_temp_dir()) === '') {
echo "Note: System temporary directory is not set.\n";
} elseif (($real = realpath($temp)) === false) {
echo "Note: System temporary directory '$temp' does not exist.\n";
} else {
$this->options['--temp'] = rtrim($real, DIRECTORY_SEPARATOR);
}
}
return $cmd;
}
private function createPhpInterpreter(): void
{
$args = $this->options['-C'] ? [] : ['-n'];
if ($this->options['-c']) {
array_push($args, '-c', $this->options['-c']);
} elseif (!$this->options['--info'] && !$this->options['-C']) {
echo "Note: No php.ini is used.\n";
}
if (in_array($this->options['-o'], ['tap', 'junit'], true)) {
array_push($args, '-d', 'html_errors=off');
}
foreach ($this->options['-d'] as $item) {
array_push($args, '-d', $item);
}
$this->interpreter = new PhpInterpreter($this->options['-p'], $args);
if ($error = $this->interpreter->getStartupError()) {
echo Dumper::color('red', "PHP startup error: $error") . "\n";
}
}
private function createRunner(): Runner
{
$runner = new Runner($this->interpreter);
$runner->paths = $this->options['paths'];
$runner->threadCount = max(1, (int) $this->options['-j']);
$runner->stopOnFail = $this->options['--stop-on-fail'];
if ($this->options['--temp'] !== null) {
$runner->setTempDirectory($this->options['--temp']);
}
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($file, PATHINFO_EXTENSION) === 'xml') {
$generator = new CodeCoverage\Generators\CloverXMLGenerator($file, $this->options['--coverage-src']);
} else {
$generator = new CodeCoverage\Generators\HtmlGenerator($file, $this->options['--coverage-src']);
}
$generator->render($file);
echo round($generator->getCoveredPercent()) . "% covered\n";
}
private function watch(Runner $runner): void
{
$prev = [];
$counter = 0;
while (true) {
$state = [];
foreach ($this->options['--watch'] as $directory) {
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) as $file) {
if (substr($file->getExtension(), 0, 3) === 'php' && substr($file->getBasename(), 0, 1) !== '.') {
$state[(string) $file] = @md5_file((string) $file); // @ file could be deleted in the meantime
}
}
}
if ($state !== $prev) {
$prev = $state;
try {
$runner->run();
} catch (\ErrorException $e) {
$this->displayException($e);
}
echo "\n";
}
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 $severity, string $message, string $file, int $line) {
if (($severity & error_reporting()) === $severity) {
throw new \ErrorException($message, 0, $severity, $file, $line);
}
return false;
});
set_exception_handler(function (\Throwable $e) {
$this->displayException($e);
exit(2);
});
}
private function displayException(\Throwable $e): void
{
echo "\n";
echo $this->debugMode
? Dumper::dumpException($e)
: Dumper::color('white/red', 'Error: ' . $e->getMessage());
echo "\n";
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester\Runner;
/**
* Stupid command line arguments parser.
*/
class CommandLine
{
public const
ARGUMENT = 'argument',
OPTIONAL = 'optional',
REPEATABLE = 'repeatable',
ENUM = 'enum',
REALPATH = 'realpath',
VALUE = 'default';
/** @var array[] */
private $options = [];
/** @var string[] */
private $aliases = [];
/** @var bool[] */
private $positional = [];
/** @var string */
private $help;
public function __construct(string $help, array $defaults = [])
{
$this->help = $help;
$this->options = $defaults;
preg_match_all('#^[ \t]+(--?\w.*?)(?: .*\(default: (.*)\)| |\r|$)#m', $help, $lines, PREG_SET_ORDER);
foreach ($lines as $line) {
preg_match_all('#(--?\w[\w-]*)(?:[= ](<.*?>|\[.*?]|\w+)(\.{0,3}))?[ ,|]*#A', $line[1], $m);
if (!count($m[0]) || count($m[0]) > 2 || implode('', $m[0]) !== $line[1]) {
throw new \InvalidArgumentException("Unable to parse '$line[1]'.");
}
$name = end($m[1]);
$opts = $this->options[$name] ?? [];
$this->options[$name] = $opts + [
self::ARGUMENT => (bool) end($m[2]),
self::OPTIONAL => isset($line[2]) || (substr(end($m[2]), 0, 1) === '[') || isset($opts[self::VALUE]),
self::REPEATABLE => (bool) end($m[3]),
self::ENUM => count($enums = explode('|', trim(end($m[2]), '<[]>'))) > 1 ? $enums : null,
self::VALUE => $line[2] ?? null,
];
if ($name !== $m[1][0]) {
$this->aliases[$m[1][0]] = $name;
}
}
foreach ($this->options as $name => $foo) {
if ($name[0] !== '-') {
$this->positional[] = $name;
}
}
}
public function parse(array $args = null): array
{
if ($args === null) {
$args = isset($_SERVER['argv']) ? array_slice($_SERVER['argv'], 1) : [];
}
$params = [];
reset($this->positional);
$i = 0;
while ($i < count($args)) {
$arg = $args[$i++];
if ($arg[0] !== '-') {
if (!current($this->positional)) {
throw new \Exception("Unexpected parameter $arg.");
}
$name = current($this->positional);
$this->checkArg($this->options[$name], $arg);
if (empty($this->options[$name][self::REPEATABLE])) {
$params[$name] = $arg;
next($this->positional);
} else {
$params[$name][] = $arg;
}
continue;
}
[$name, $arg] = strpos($arg, '=') ? explode('=', $arg, 2) : [$arg, true];
if (isset($this->aliases[$name])) {
$name = $this->aliases[$name];
} elseif (!isset($this->options[$name])) {
throw new \Exception("Unknown option $name.");
}
$opt = $this->options[$name];
if ($arg !== true && empty($opt[self::ARGUMENT])) {
throw new \Exception("Option $name has not argument.");
} elseif ($arg === true && !empty($opt[self::ARGUMENT])) {
if (isset($args[$i]) && $args[$i][0] !== '-') {
$arg = $args[$i++];
} elseif (empty($opt[self::OPTIONAL])) {
throw new \Exception("Option $name requires argument.");
}
}
if (!empty($opt[self::ENUM]) && !in_array($arg, $opt[self::ENUM], true) && !($opt[self::OPTIONAL] && $arg === true)) {
throw new \Exception("Value of option $name must be " . implode(', or ', $opt[self::ENUM]) . '.');
}
$this->checkArg($opt, $arg);
if (empty($opt[self::REPEATABLE])) {
$params[$name] = $arg;
} else {
$params[$name][] = $arg;
}
}
foreach ($this->options as $name => $opt) {
if (isset($params[$name])) {
continue;
} elseif (isset($opt[self::VALUE])) {
$params[$name] = $opt[self::VALUE];
} elseif ($name[0] !== '-' && empty($opt[self::OPTIONAL])) {
throw new \Exception("Missing required argument <$name>.");
} else {
$params[$name] = null;
}
if (!empty($opt[self::REPEATABLE])) {
$params[$name] = (array) $params[$name];
}
}
return $params;
}
public function help(): void
{
echo $this->help;
}
public function checkArg(array $opt, &$arg): void
{
if (!empty($opt[self::REALPATH])) {
$path = realpath($arg);
if ($path === false) {
throw new \Exception("File path '$arg' not found.");
}
$arg = $path;
}
}
public function isEmpty(): bool
{
return !isset($_SERVER['argv']) || count($_SERVER['argv']) < 2;
}
}
<?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', $extensions, true) || in_array('pcov', $extensions, true),
];
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";
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester\Runner;
use Tester\Helpers;
/**
* Single test job.
*/
class Job
{
public const
CODE_NONE = -1,
CODE_OK = 0,
CODE_SKIP = 177,
CODE_FAIL = 178,
CODE_ERROR = 255;
/** waiting time between process activity check in microseconds */
public const RUN_USLEEP = 10000;
public const
RUN_ASYNC = 1,
RUN_COLLECT_ERRORS = 2;
/** @var Test */
private $test;
/** @var PhpInterpreter */
private $interpreter;
/** @var string[] environment variables for test */
private $envVars;
/** @var resource|null */
private $proc;
/** @var resource|null */
private $stdout;
/** @var resource|null */
private $stderr;
/** @var int */
private $exitCode = self::CODE_NONE;
/** @var string[] output headers */
private $headers = [];
public function __construct(Test $test, PhpInterpreter $interpreter, array $envVars = null)
{
if ($test->getResult() !== Test::PREPARED) {
throw new \LogicException("Test '{$test->getSignature()}' already has result '{$test->getResult()}'.");
}
$test->stdout = '';
$test->stderr = '';
$this->test = $test;
$this->interpreter = $interpreter;
$this->envVars = (array) $envVars;
}
public function setEnvironmentVariable(string $name, string $value): void
{
$this->envVars[$name] = $value;
}
public function getEnvironmentVariable(string $name): string
{
return $this->envVars[$name];
}
/**
* Runs single test.
* @param int $flags self::RUN_ASYNC | self::RUN_COLLECT_ERRORS
*/
public function run(int $flags = 0): void
{
foreach ($this->envVars as $name => $value) {
putenv("$name=$value");
}
$args = [];
foreach ($this->test->getArguments() as $value) {
if (is_array($value)) {
$args[] = Helpers::escapeArg("--$value[0]=$value[1]");
} else {
$args[] = Helpers::escapeArg($value);
}
}
$this->proc = proc_open(
$this->interpreter->getCommandLine()
. ' -d register_argc_argv=on ' . Helpers::escapeArg($this->test->getFile()) . ' ' . implode(' ', $args),
[
['pipe', 'r'],
['pipe', 'w'],
['pipe', 'w'],
],
$pipes,
dirname($this->test->getFile()),
null,
['bypass_shell' => true]
);
foreach (array_keys($this->envVars) as $name) {
putenv($name);
}
[$stdin, $this->stdout, $stderr] = $pipes;
fclose($stdin);
if ($flags & self::RUN_COLLECT_ERRORS) {
$this->stderr = $stderr;
} else {
fclose($stderr);
}
if ($flags & self::RUN_ASYNC) {
stream_set_blocking($this->stdout, false); // on Windows does not work with proc_open()
if ($this->stderr) {
stream_set_blocking($this->stderr, false);
}
} else {
while ($this->isRunning()) {
usleep(self::RUN_USLEEP); // stream_select() doesn't work with proc_open()
}
}
}
/**
* Checks if the test is still running.
*/
public function isRunning(): bool
{
if (!is_resource($this->stdout)) {
return false;
}
$this->test->stdout .= stream_get_contents($this->stdout);
if ($this->stderr) {
$this->test->stderr .= stream_get_contents($this->stderr);
}
$status = proc_get_status($this->proc);
if ($status['running']) {
return true;
}
fclose($this->stdout);
if ($this->stderr) {
fclose($this->stderr);
}
$code = proc_close($this->proc);
$this->exitCode = $code === self::CODE_NONE ? $status['exitcode'] : $code;
if ($this->interpreter->isCgi() && count($tmp = explode("\r\n\r\n", $this->test->stdout, 2)) >= 2) {
[$headers, $this->test->stdout] = $tmp;
foreach (explode("\r\n", $headers) as $header) {
$pos = strpos($header, ':');
if ($pos !== false) {
$this->headers[trim(substr($header, 0, $pos))] = trim(substr($header, $pos + 1));
}
}
}
return false;
}
public function getTest(): Test
{
return $this->test;
}
/**
* Returns exit code.
*/
public function getExitCode(): int
{
return $this->exitCode;
}
/**
* Returns output headers.
* @return string[]
*/
public function getHeaders(): array
{
return $this->headers;
}
}
<?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 $runner, bool $displaySkipped = false, string $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 > 1 ? 's' : '') . "\n\n");
}
public function prepare(Test $test): void
{
if ($this->baseDir === null) {
$this->baseDir = dirname($test->getFile()) . DIRECTORY_SEPARATOR;
} elseif (strpos($test->getFile(), $this->baseDir) !== 0) {
$common = array_intersect_assoc(
explode(DIRECTORY_SEPARATOR, $this->baseDir),
explode(DIRECTORY_SEPARATOR, $test->getFile())
);
$this->baseDir = '';
$prev = 0;
foreach ($common as $i => $part) {
if ($i !== $prev++) {
break;
}
$this->baseDir .= $part . DIRECTORY_SEPARATOR;
}
}
$this->count++;
}
public function finish(Test $test): void
{
$this->results[$test->getResult()]++;
$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 > 1 ? 's' : '') . ', '
. ($this->results[Test::FAILED] ? $this->results[Test::FAILED] . ' failure' . ($this->results[Test::FAILED] > 1 ? 's' : '') . ', ' : '')
. ($this->results[Test::SKIPPED] ? $this->results[Test::SKIPPED] . ' skipped, ' : '')
. ($this->count !== $run ? ($this->count - $run) . ' not run, ' : '')
. sprintf('%0.1f', $this->time + microtime(true)) . ' seconds)' . Dumper::color() . "\n");
$this->buffer = null;
}
}
<?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");
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester\Runner\Output;
use Tester;
use Tester\Runner\Runner;
use Tester\Runner\Test;
/**
* Verbose logger.
*/
class Logger implements Tester\Runner\OutputHandler
{
/** @var Runner */
private $runner;
/** @var resource */
private $file;
/** @var int */
private $count;
/** @var array */
private $results;
public function __construct(Runner $runner, string $file = 'php://output')
{
$this->runner = $runner;
$this->file = fopen($file, 'w');
}
public function begin(): void
{
$this->count = 0;
$this->results = [
Test::PASSED => 0,
Test::SKIPPED => 0,
Test::FAILED => 0,
];
fwrite($this->file, 'PHP ' . $this->runner->getInterpreter()->getVersion()
. ' | ' . $this->runner->getInterpreter()->getCommandLine()
. " | {$this->runner->threadCount} threads\n\n");
}
public function prepare(Test $test): void
{
$this->count++;
}
public function finish(Test $test): void
{
$this->results[$test->getResult()]++;
$message = ' ' . str_replace("\n", "\n ", Tester\Dumper::removeColors(trim((string) $test->message)));
$outputs = [
Test::PASSED => "-- OK: {$test->getSignature()}",
Test::SKIPPED => "-- Skipped: {$test->getSignature()}\n$message",
Test::FAILED => "-- FAILED: {$test->getSignature()}\n$message",
];
fwrite($this->file, $outputs[$test->getResult()] . "\n\n");
}
public function end(): void
{
$run = array_sum($this->results);
fwrite($this->file,
($this->results[Test::FAILED] ? 'FAILURES!' : 'OK')
. " ($this->count tests"
. ($this->results[Test::FAILED] ? ", {$this->results[Test::FAILED]} failures" : '')
. ($this->results[Test::SKIPPED] ? ", {$this->results[Test::SKIPPED]} skipped" : '')
. ($this->count !== $run ? ', ' . ($this->count - $run) . ' not run' : '')
. ')'
);
}
}
<?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));
}
}
<?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;
}
<?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", $output, 2);
$this->cgi = count($parts) === 2;
$this->info = @unserialize(strstr($parts[$this->cgi], 'O:8:"stdClass"'));
$this->error .= strstr($parts[$this->cgi], 'O:8:"stdClass"', true);
if (!$this->info) {
throw new \Exception("Unable to detect PHP version (output: $output).");
} elseif ($this->info->phpDbgVersion && version_compare($this->info->version, '7.0.0', '<')) {
throw new \Exception('Unable to use phpdbg on PHP < 7.0.0.');
} elseif ($this->cgi && $this->error) {
$this->error .= "\n(note that PHP CLI generates better error messages)";
}
}
/**
* @return static
*/
public function withPhpIniOption(string $name, string $value = null): self
{
$me = clone $this;
$me->commandLine .= ' -d ' . Helpers::escapeArg($name . ($value === null ? '' : "=$value"));
return $me;
}
public function getCommandLine(): string
{
return $this->commandLine;
}
public function getVersion(): string
{
return $this->info->version;
}
public function 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);
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester\Runner;
use Tester\Environment;
/**
* Test runner.
*/
class Runner
{
/** @var string[] paths to test files/directories */
public $paths = [];
/** @var int run in parallel threads */
public $threadCount = 1;
/** @var TestHandler */
public $testHandler;
/** @var OutputHandler[] */
public $outputHandlers = [];
/** @var bool */
public $stopOnFail = false;
/** @var PhpInterpreter */
private $interpreter;
/** @var array */
private $envVars = [];
/** @var Job[] */
private $jobs;
/** @var bool */
private $interrupted = false;
/** @var string|null */
private $tempDir;
/** @var bool */
private $result;
/** @var array */
private $lastResults = [];
public function __construct(PhpInterpreter $interpreter)
{
$this->interpreter = $interpreter;
$this->testHandler = new TestHandler($this);
}
public function setEnvironmentVariable(string $name, string $value): void
{
$this->envVars[$name] = $value;
}
public function getEnvironmentVariables(): array
{
return $this->envVars;
}
public function setTempDirectory(?string $path): void
{
if ($path !== null) {
if (!is_dir($path) || !is_writable($path)) {
throw new \RuntimeException("Path '$path' is not a writable directory.");
}
$path = realpath($path) . DIRECTORY_SEPARATOR . 'Tester';
if (!is_dir($path) && @mkdir($path) === false && !is_dir($path)) { // @ - directory may exist
throw new \RuntimeException("Cannot create '$path' directory.");
}
}
$this->tempDir = $path;
}
/**
* Runs all tests.
*/
public function run(): bool
{
$this->result = true;
$this->interrupted = false;
foreach ($this->outputHandlers as $handler) {
$handler->begin();
}
$this->jobs = $running = [];
foreach ($this->paths as $path) {
$this->findTests($path);
}
if ($this->tempDir) {
usort($this->jobs, function (Job $a, Job $b): int {
return $this->getLastResult($a->getTest()) - $this->getLastResult($b->getTest());
});
}
$threads = range(1, $this->threadCount);
$this->installInterruptHandler();
while (($this->jobs || $running) && !$this->isInterrupted()) {
while ($threads && $this->jobs) {
$running[] = $job = array_shift($this->jobs);
$async = $this->threadCount > 1 && (count($running) + count($this->jobs) > 1);
$job->setEnvironmentVariable(Environment::THREAD, (string) array_shift($threads));
$job->run($async ? $job::RUN_ASYNC : 0);
}
if (count($running) > 1) {
usleep(Job::RUN_USLEEP); // stream_select() doesn't work with proc_open()
}
foreach ($running as $key => $job) {
if ($this->isInterrupted()) {
break 2;
}
if (!$job->isRunning()) {
$threads[] = $job->getEnvironmentVariable(Environment::THREAD);
$this->testHandler->assess($job);
unset($running[$key]);
}
}
}
$this->removeInterruptHandler();
foreach ($this->outputHandlers as $handler) {
$handler->end();
}
return $this->result;
}
private function findTests(string $path): void
{
if (strpbrk($path, '*?') === false && !file_exists($path)) {
throw new \InvalidArgumentException("File or directory '$path' not found.");
}
if (is_dir($path)) {
foreach (glob(str_replace('[', '[[]', $path) . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
$this->findTests($dir);
}
$this->findTests($path . '/*.phpt');
$this->findTests($path . '/*Test.php');
} else {
foreach (glob(str_replace('[', '[[]', $path)) ?: [] as $file) {
if (is_file($file)) {
$this->testHandler->initiate(realpath($file));
}
}
}
}
/**
* Appends new job to queue.
*/
public function addJob(Job $job): void
{
$this->jobs[] = $job;
}
public function prepareTest(Test $test): void
{
foreach ($this->outputHandlers as $handler) {
$handler->prepare($test);
}
}
/**
* Writes to output handlers.
*/
public function finishTest(Test $test): void
{
$this->result = $this->result && ($test->getResult() !== Test::FAILED);
foreach ($this->outputHandlers as $handler) {
$handler->finish($test);
}
if ($this->tempDir) {
$lastResult = &$this->lastResults[$test->getSignature()];
if ($lastResult !== $test->getResult()) {
file_put_contents($this->getLastResultFilename($test), $lastResult = $test->getResult());
}
}
if ($this->stopOnFail && $test->getResult() === Test::FAILED) {
$this->interrupted = true;
}
}
public function getInterpreter(): PhpInterpreter
{
return $this->interpreter;
}
private function installInterruptHandler(): void
{
if (extension_loaded('pcntl')) {
pcntl_signal(SIGINT, function (): void {
pcntl_signal(SIGINT, SIG_DFL);
$this->interrupted = true;
});
}
}
private function removeInterruptHandler(): void
{
if (extension_loaded('pcntl')) {
pcntl_signal(SIGINT, SIG_DFL);
}
}
private function isInterrupted(): bool
{
if (extension_loaded('pcntl')) {
pcntl_signal_dispatch();
}
return $this->interrupted;
}
private function getLastResult(Test $test): int
{
$signature = $test->getSignature();
if (isset($this->lastResults[$signature])) {
return $this->lastResults[$signature];
}
$file = $this->getLastResultFilename($test);
if (is_file($file)) {
return $this->lastResults[$signature] = (int) file_get_contents($file);
}
return $this->lastResults[$signature] = Test::PREPARED;
}
private function getLastResultFilename(Test $test): string
{
return $this->tempDir
. DIRECTORY_SEPARATOR
. pathinfo($test->getFile(), PATHINFO_FILENAME)
. '.'
. substr(md5($test->getSignature()), 0, 5)
. '.result';
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester\Runner;
/**
* Test represents one result.
*/
class Test
{
public const
PREPARED = 0,
FAILED = 1,
PASSED = 2,
SKIPPED = 3;
/** @var string|null */
public $title;
/** @var string|null */
public $message;
/** @var string */
public $stdout = '';
/** @var string */
public $stderr = '';
/** @var string */
private $file;
/** @var int */
private $result = self::PREPARED;
/** @var string[]|string[][] */
private $args = [];
public function __construct(string $file, string $title = null)
{
$this->file = $file;
$this->title = $title;
}
public function getFile(): string
{
return $this->file;
}
/**
* @return string[]|string[][]
*/
public function getArguments(): array
{
return $this->args;
}
public function getSignature(): string
{
$args = implode(' ', array_map(function ($arg): string {
return is_array($arg) ? "$arg[0]=$arg[1]" : $arg;
}, $this->args));
return $this->file . ($args ? " $args" : '');
}
public function getResult(): int
{
return $this->result;
}
public function hasResult(): bool
{
return $this->result !== self::PREPARED;
}
/**
* @return static
*/
public function withArguments(array $args): self
{
if ($this->hasResult()) {
throw new \LogicException('Cannot change arguments of test which already has a result.');
}
$me = clone $this;
foreach ($args as $name => $values) {
foreach ((array) $values as $value) {
$me->args[] = is_int($name)
? "$value"
: [$name, "$value"];
}
}
return $me;
}
/**
* @return static
*/
public function withResult(int $result, ?string $message): self
{
if ($this->hasResult()) {
throw new \LogicException("Result of test is already set to $this->result with message '$this->message'.");
}
$me = clone $this;
$me->result = $result;
$me->message = $message;
return $me;
}
}
<?php
/**
* This file is part of the Nette Tester.
* Copyright (c) 2009 David Grudl (https://davidgrudl.com)
*/
declare(strict_types=1);
namespace Tester\Runner;
use Tester;
use Tester\Dumper;
use Tester\Helpers;
use Tester\TestCase;
/**
* Default test behavior.
*/
class TestHandler
{
private const HTTP_OK = 200;
/** @var Runner */
private $runner;
public function __construct(Runner $runner)
{
$this->runner = $runner;
}
public function initiate(string $file): void
{
[$annotations, $title] = $this->getAnnotations($file);
$php = $this->runner->getInterpreter();
$tests = [new Test($file, $title)];
foreach (get_class_methods($this) as $method) {
if (!preg_match('#^initiate(.+)#', strtolower($method), $m) || !isset($annotations[$m[1]])) {
continue;
}
foreach ((array) $annotations[$m[1]] as $value) {
/** @var Test[] $prepared */
$prepared = [];
foreach ($tests as $test) {
$res = $this->$method($test, $value, $php);
if ($res === null) {
$prepared[] = $test;
} else {
foreach (is_array($res) ? $res : [$res] as $testVariety) {
/** @var Test $testVariety */
if ($testVariety->hasResult()) {
$this->runner->prepareTest($testVariety);
$this->runner->finishTest($testVariety);
} else {
$prepared[] = $testVariety;
}
}
}
}
$tests = $prepared;
}
}
foreach ($tests as $test) {
$this->runner->prepareTest($test);
$this->runner->addJob(new Job($test, $php, $this->runner->getEnvironmentVariables()));
}
}
public function assess(Job $job): void
{
$test = $job->getTest();
$annotations = $this->getAnnotations($test->getFile())[0] += [
'exitcode' => Job::CODE_OK,
'httpcode' => self::HTTP_OK,
];
foreach (get_class_methods($this) as $method) {
if (!preg_match('#^assess(.+)#', strtolower($method), $m) || !isset($annotations[$m[1]])) {
continue;
}
foreach ((array) $annotations[$m[1]] as $arg) {
/** @var Test|null $res */
if ($res = $this->$method($job, $arg)) {
$this->runner->finishTest($res);
return;
}
}
}
$this->runner->finishTest($test->withResult(Test::PASSED, $test->message));
}
private function initiateSkip(Test $test, string $message): Test
{
return $test->withResult(Test::SKIPPED, $message);
}
private function initiatePhpVersion(Test $test, string $version, PhpInterpreter $interpreter): ?Test
{
if (preg_match('#^(<=|<|==|=|!=|<>|>=|>)?\s*(.+)#', $version, $matches)
&& version_compare($matches[2], $interpreter->getVersion(), $matches[1] ?: '>=')) {
return $test->withResult(Test::SKIPPED, "Requires PHP $version.");
}
return null;
}
private function initiatePhpExtension(Test $test, string $value, PhpInterpreter $interpreter): ?Test
{
foreach (preg_split('#[\s,]+#', $value) as $extension) {
if (!$interpreter->hasExtension($extension)) {
return $test->withResult(Test::SKIPPED, "Requires PHP extension $extension.");
}
}
return null;
}
private function initiatePhpIni(Test $test, string $pair, PhpInterpreter &$interpreter): void
{
[$name, $value] = explode('=', $pair, 2) + [1 => null];
$interpreter = $interpreter->withPhpIniOption($name, $value);
}
private function initiateDataProvider(Test $test, string $provider)
{
try {
[$dataFile, $query, $optional] = Tester\DataProvider::parseAnnotation($provider, $test->getFile());
$data = Tester\DataProvider::load($dataFile, $query);
} catch (\Exception $e) {
return $test->withResult(empty($optional) ? Test::FAILED : Test::SKIPPED, $e->getMessage());
}
return array_map(function (string $item) use ($test, $dataFile): Test {
return $test->withArguments(['dataprovider' => "$item|$dataFile"]);
}, array_keys($data));
}
private function initiateMultiple(Test $test, $count): array
{
return array_map(function (int $i) use ($test): Test {
return $test->withArguments(['multiple' => $i]);
}, range(0, (int) $count - 1));
}
private function initiateTestCase(Test $test, $foo, PhpInterpreter $interpreter)
{
$job = new Job($test->withArguments(['method' => TestCase::LIST_METHODS]), $interpreter);
$job->run();
if (in_array($job->getExitCode(), [Job::CODE_ERROR, Job::CODE_FAIL, Job::CODE_SKIP], true)) {
return $test->withResult($job->getExitCode() === Job::CODE_SKIP ? Test::SKIPPED : Test::FAILED, $job->getTest()->stdout);
}
if (!preg_match('#\[([^[]*)]#', (string) strrchr($job->getTest()->stdout, '['), $m)) {
return $test->withResult(Test::FAILED, "Cannot list TestCase methods in file '{$test->getFile()}'. Do you call TestCase::run() in it?");
} elseif (!strlen($m[1])) {
return $test->withResult(Test::SKIPPED, "TestCase in file '{$test->getFile()}' does not contain test methods.");
}
return array_map(function (string $method) use ($test): Test {
return $test->withArguments(['method' => $method]);
}, explode(',', $m[1]));
}
private function assessExitCode(Job $job, $code): ?Test
{
$code = (int) $code;
if ($job->getExitCode() === Job::CODE_SKIP) {
$message = preg_match('#.*Skipped:\n(.*?)\z#s', $output = $job->getTest()->stdout, $m)
? $m[1]
: $output;
return $job->getTest()->withResult(Test::SKIPPED, trim($message));
} elseif ($job->getExitCode() !== $code) {
$message = $job->getExitCode() !== Job::CODE_FAIL ? "Exited with error code {$job->getExitCode()} (expected $code)" : '';
return $job->getTest()->withResult(Test::FAILED, trim($message . "\n" . $job->getTest()->stdout));
}
return null;
}
private function assessHttpCode(Job $job, $code): ?Test
{
if (!$this->runner->getInterpreter()->isCgi()) {
return null;
}
$headers = $job->getHeaders();
$actual = (int) ($headers['Status'] ?? self::HTTP_OK);
$code = (int) $code;
return $code && $code !== $actual
? $job->getTest()->withResult(Test::FAILED, "Exited with HTTP code $actual (expected $code)")
: null;
}
private function assessOutputMatchFile(Job $job, string $file): ?Test
{
$file = dirname($job->getTest()->getFile()) . DIRECTORY_SEPARATOR . $file;
if (!is_file($file)) {
return $job->getTest()->withResult(Test::FAILED, "Missing matching file '$file'.");
}
return $this->assessOutputMatch($job, file_get_contents($file));
}
private function assessOutputMatch(Job $job, string $content): ?Test
{
$actual = $job->getTest()->stdout;
if (!Tester\Assert::isMatching($content, $actual)) {
[$content, $actual] = Tester\Assert::expandMatchingPatterns($content, $actual);
Dumper::saveOutput($job->getTest()->getFile(), $actual, '.actual');
Dumper::saveOutput($job->getTest()->getFile(), $content, '.expected');
return $job->getTest()->withResult(Test::FAILED, 'Failed: output should match ' . Dumper::toLine($content));
}
return null;
}
private function getAnnotations(string $file): array
{
$annotations = Helpers::parseDocComment(file_get_contents($file));
$testTitle = isset($annotations[0]) ? preg_replace('#^TEST:\s*#i', '', $annotations[0]) : null;
return [$annotations, $testTitle];
}
}
<?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());