diff --git a/.travis.yml b/.travis.yml index b9c764b5..8075f52f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -61,6 +61,7 @@ jobs: allow_failures: - stage: Static Analysis (informative) - stage: Code Coverage + - php: nightly dist: xenial diff --git a/src/CodeCoverage/Collector.php b/src/CodeCoverage/Collector.php index 1e80412d..585b6b52 100644 --- a/src/CodeCoverage/Collector.php +++ b/src/CodeCoverage/Collector.php @@ -30,9 +30,9 @@ class Collector public static function detectEngines(): array { return array_filter([ - extension_loaded('pcov') ? self::ENGINE_PCOV : null, - defined('PHPDBG_VERSION') ? self::ENGINE_PHPDBG : null, - extension_loaded('xdebug') ? self::ENGINE_XDEBUG : null, + extension_loaded('pcov') ? [self::ENGINE_PCOV, phpversion('pcov')] : null, + defined('PHPDBG_VERSION') ? [self::ENGINE_PHPDBG, PHPDBG_VERSION] : null, + extension_loaded('xdebug') ? [self::ENGINE_XDEBUG, phpversion('xdebug')] : null, ]); } @@ -52,7 +52,11 @@ public static function start(string $file, string $engine): void if (self::isStarted()) { throw new \LogicException('Code coverage collector has been already started.'); - } elseif (!in_array($engine, self::detectEngines(), true)) { + } elseif (!in_array( + $engine, + array_map(function (array $engineInfo) { return $engineInfo[0]; }, self::detectEngines()), + true + )) { throw new \LogicException("Code coverage engine '$engine' is not supported."); } diff --git a/src/CodeCoverage/Generators/template.phtml b/src/CodeCoverage/Generators/template.phtml index 4db2a3e3..0fb4e126 100644 --- a/src/CodeCoverage/Generators/template.phtml +++ b/src/CodeCoverage/Generators/template.phtml @@ -10,12 +10,12 @@ <style type="text/css"> html { font: 14px/1.5 Verdana,"Geneva CE",lucida,sans-serif; - border-top: 4.7em solid #f4ebdb; + border-top: 7.7em solid #f4ebdb; } body { max-width: 990px; - margin: -4.7em auto 0; + margin: -7.7em auto 0; background: #fcfaf5; color: #333; } @@ -27,11 +27,15 @@ h1 { font-family: "Trebuchet MS","Geneva CE",lucida,sans-serif; font-size: 1.9em; - margin: .5em .5em 1.5em; + margin: .5em; color: #7a7772; text-shadow: 1px 1px 0 white; } + h1 small { + font-size: 60%; + } + div.code { background: white; border: 1px dotted silver; @@ -64,6 +68,7 @@ aside a { color: #c0c0c0; + display: block; } aside a:hover { @@ -78,6 +83,10 @@ position: relative; } + i { + font-style: normal; + } + a { color: #006aeb; text-decoration: none; @@ -108,6 +117,13 @@ height: 1em; } + .bar-empty { + border: 1px solid transparent; + background: transparent; + width: 35px; + height: 1em; + } + .bar div { background: #1a7e1e; height: 1em; @@ -133,78 +149,420 @@ code .hh { color: #06B; } code .hk { color: #e71818; } code .hs { color: #008000; } + + .tabs { + margin-bottom: 1.5em; + } + + .tabs a { + padding: .5em 1.5em; + display: inline-block; + font-size: 17px; + } + + .tabs a.active, + .tabs a:focus, + .tabs a:hover { + background-color: #fcfaf5; + text-decoration: none; + } + + .directory { + display: none; + margin-left: 1.5rem; + } + + .directory.opened { + display: block; + } + + .toggle-dir { + color: brown; + } + + .toggle-dir:before { + content: '▼ '; + } + + .toggle-dir.opened:before { + content: '▲ '; + } + + .sort .up, .sort .no-sort, .sort .down { + display: inline-block; + padding-left: 5px; + color: #c9302c + } + + .sort .no-sort { + color: #ccc + } + + .sort .up, .sort .down, .sort .no-sort, + .sort.desc:hover .up, .sort.desc:hover .down, + .sort.asc .no-sort, .sort.down .no-sort, .sort.asc:hover .up, .sort:hover .no-sort { + display: none + } + + .sort:hover .up, .sort.asc .up, + .sort.desc:hover .no-sort, + .sort.asc:hover .down, .sort.desc .down { + display: inline-block + } </style> </head> <body> - <h1><?= $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage <?= round($coveredPercent) ?> %</h1> - <?php foreach ($files as $id => $info): ?> - <div> +<?php + +function assignArrayByPath(&$arr, $info, $value, $separator = '/') { + $keys = explode($separator, $info->name); + + $currentFile = ''; + foreach ($keys as $key) { + $currentFile = $currentFile . ($currentFile !== '' ? '/' : '') . $key; + $arr = &$arr['files'][$key]; + + if (!isset($arr['name'])) { + $arr['name'] = $currentFile; + } + $arr['count'] = isset($arr['count']) ? $arr['count'] + 1 : 1; + $arr['coverage'] = isset($arr['coverage']) ? $arr['coverage'] + $info->coverage : $info->coverage; + } + $arr = $value; +} + +$jsonData = []; +$directories = []; +$allLinesCount = 0; +foreach ($files as $id => $info) { + $code = file_get_contents($info->file); + $lineCount = substr_count($code, "\n") + 1; + $digits = ceil(log10($lineCount)) + 1; + + $allLinesCount += $lineCount; + + $currentId = "F{$id}"; + assignArrayByPath($directories, $info, $currentId); + + $data = (array) $info; + $data['digits'] = $digits; + $data['lineCount'] = $lineCount; + $data['content'] = strtr(highlight_string($code, true), [ + '<code>' => "<code style='margin-left: {$digits}em'>", + '<span style="color: ' => '<i class="', + '</span>' => '</i>', + '<br />' => '<br>', + ]); + $jsonData[$currentId] = $data; +} ?> + +<h1> + <?= $title ? htmlspecialchars("$title - ") : ''; ?>Code coverage <?= round($coveredPercent) ?> % + <small>sources have <?= number_format($allLinesCount) ?> lines of code in <?= number_format(count($files)) ?> files</small> +</h1> + +<nav class="tabs"> + <a href="#list" class="tab-item active">List</a> + <a href="#tree" class="tab-item">Tree</a> +</nav> + +<div class="tabs-content"> + <div id="list" class="tab-content"> <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> + <tbody> + <tr> + <td class="number"> + <a href="#" class="sort desc" data-sort="coverage"><span class="up">↓</span><span class="down">↑</span><span class="no-sort"> </span>%</a> + </td> + <td> + <div class="bar-empty"></div> + </td> + <td> + <a href="#" class="sort" data-sort="path">path<span class="up">↓</span><span class="down">↑</span><span class="no-sort"> </span></a> + </td> + </tr> + </tbody> </table> + <div id="files-content"></div> + </div> + <div id="tree" class="tab-content" style="display: none"></div> +</div> + +<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> + (function() { + let _files = <?= json_encode($jsonData) ?>; + let _directories = <?= json_encode($directories) ?>; + let isFilesInitialized = false; + let isDirectoriesInitialized = false; + + let sort = 'coverage'; + let desc = true; + let classes = { + '1': 't', // tested + '-1': 'u', // untested + '-2': 'dead', // dead code + }; + + function getSortedKeys() { + switch (sort) { + case 'coverage': + let sortedFiles = []; + for (let i in _files) { + sortedFiles.push([i, _files[i].coverage]); + } + + sortedFiles.sort(function(a, b) { + return desc ? (b[1] - a[1]) : (a[1] - b[1]); + }); + + let filesByCoverage = []; + for (let i = 0; i < sortedFiles.length; i++) { + filesByCoverage.push(sortedFiles[i][0]); + } + return filesByCoverage; + + case 'path': + return sortAlphabetically(_files, desc); + + default: + return Object.keys(_files).sort((a, b) => desc ? (b-a) : (a-b)); + } + } + + function sortAlphabetically(obj, isDesc) { + let sorted = []; + for (let i in obj) { + sorted.push([i, obj[i].name]); + } - <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'" : '') . '>'; + sorted.sort(function(a, b) { + let compared = a[1].localeCompare(b[1]); + return compared === -1 ? (isDesc ? 1 : -1) : (compared === 1 ? (isDesc ? -1 : 1) : 0); + }); + + let out = []; + for (let i = 0; i < sorted.length; i++) { + out.push(sorted[i][0]); + } + return out; + } + + function initDirectories() { + if (isDirectoriesInitialized) { + return; + } + let el = document.getElementById('tree'); + el.innerHTML = createDirectory(_directories.files); + isDirectoriesInitialized = true; + } + + function createDirectory(directory) { + let out = ''; + Object.keys(directory).sort(function(a, b) { + let aIsFile = a.endsWith('.php'); + let bIsFile = b.endsWith('.php'); + if ((aIsFile || bIsFile) && !(aIsFile && bIsFile)) { + return aIsFile ? 1 : -1; + } + let compared = a.localeCompare(b); + return compared === -1 ? -1 : (compared === 1 ? 1 : 0); + }).forEach(function(i) { + let dir = directory[i]; + let id, coverage; + let isFile = typeof dir === 'string'; + if (isFile) { + id = dir; + dir = _files[dir]; + coverage = dir.coverage; + } else { + id = null; + coverage = dir.count === 0 ? 0 : Math.round(dir.coverage / dir.count); } - $buffer .= "<a href='#F{$id}L{$i}' id='F{$id}L{$i}'>" . sprintf("%{$digits}s", $i) . "</a>\n"; - $prevClass = $class; + let html = createFileInfo(isFile ? ('D' + id) : dir.name, { + coverage: coverage, + name: isFile ? i : (i + ' <small>(' + dir.count + ')</small>'), + class: isFile ? dir.class : 'dir', + }, isFile ? 'toggle' : 'toggle-dir'); + + if (isFile) { + html += '<div class="code" id="D' + id + '"></div>'; + } else { + html += '<div class="directory" id="' + dir.name + '">' + createDirectory(dir.files) + '</div>'; + } + + out += '<div>' + html + '</div>'; + }); + return out; + } + + function initFiles(force) { + if (!force && isFilesInitialized) { + return; } - echo $buffer . $closeTag; + let filesEl = document.getElementById('files-content'); + filesEl.innerHTML = ''; - $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 ?> + getSortedKeys().forEach(function(i) { + let file = _files[i]; + let html = createFileInfo(i, file); + + html += '<div class="code" id="' + i + '"></div>'; + + let wrapper = document.createElement('div'); + wrapper.innerHTML = html; + filesEl.appendChild(wrapper); + }); + isFilesInitialized = true; + } + + function createFileInfo(id, file, toggleClass) { + toggleClass = toggleClass || 'toggle'; + return '<table>' + + '<tbody><tr' + (file.class ? (' class="' + file.class + '"') : '') + '>' + + '<td class="number"><small>' + file.coverage + ' %</small></td>' + + '<td><div class="bar"><div style="width: ' + file.coverage + '%"></div></div></td>' + + '<td><a href="#' + id + '" class="' + toggleClass + '">' + file.name + '</a></td>' + + '</tr>' + + '</tbody></table>'; + } + + function initFileContent(el) { + if (el.hasAttribute('data-initialized')) { + return; + } + + let id = el.getAttribute('id'); + id = id.startsWith('D') ? id.split('D')[1] : id; + let file = _files[id]; + if (!file) { + return; + } + + let aside = ''; + let prevClass = null; + let closeTag = '', buffer = ''; + for (let i = 1; i < file.lineCount; i++) { + let currentClass = file.lines[i] && classes[file.lines[i]] ? classes[file.lines[i]] : ''; + if (currentClass !== prevClass) { + aside += (buffer + closeTag); + buffer = ''; + closeTag = '</div>'; + aside += '<div' + (currentClass ? (' class="' + currentClass + '"') : '') + '>'; + } + + let currentId = id + 'L' + i; + buffer += '<a href="#' + currentId + '" id="' + currentId + '">' + ' '.repeat(file.digits - String(i).length) + i + "</a>"; + prevClass = currentClass; + } + aside += buffer + closeTag; + + el.innerHTML = '<div><aside>' + aside + '</aside>' + (file ? file.content : 'ERROR: Not available') + '</div>'; + + el.setAttribute('data-initialized', 'true'); + } + + function addClickListener(className, callback) { + document.body.addEventListener('click', function (e) { + let isParent = e.target.parentNode && e.target.parentNode.classList.contains(className); + if (isParent || e.target.classList.contains(className)) { + callback.apply(isParent ? e.target.parentNode : e.target, [e]); + } + }); + } + + addClickListener('sort', function(e) { + e.preventDefault(); + sort = this.getAttribute('data-sort'); + + let sortItems = document.getElementsByClassName('sort'); + for (let i = 0; i < sortItems.length; i++) { + if (sortItems[i] === this) { + continue; + } + sortItems[i].classList.remove('asc'); + sortItems[i].classList.remove('desc'); + } - <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> + desc = false; + if (this.classList.contains('asc')) { + desc = true; + this.classList.remove('asc'); + this.classList.add('desc'); + } else { + this.classList.remove('desc'); + this.classList.add('asc'); + } - <script> - document.body.addEventListener('click', function (e) { - if (e.target.className === 'toggle') { - var el = document.getElementById(e.target.href.split('#')[1]); + initFiles(true); + }); + + addClickListener('toggle', function(e) { + e.preventDefault(); + let el = document.getElementById(this.href.split('#')[1]); if (el.style.display === 'block') { el.style.display = 'none'; } else { + initFileContent(el); el.style.display = 'block'; } + }); + + addClickListener('toggle-dir', function(e) { e.preventDefault(); - } - }); + let el = document.getElementById(this.href.split('#')[1]); + if (el.classList.contains('opened')) { + this.classList.remove('opened'); + el.classList.remove('opened'); + } else { + this.classList.add('opened'); + el.classList.add('opened'); + } + }); - if (el = document.getElementById(window.location.hash.replace(/^#|L\d+$/g, ''))) { - el.style.display = 'block'; - } - </script> + addClickListener('tab-item', function(e) { + e.preventDefault(); + let tabs = document.getElementsByClassName('tab-content'); + for (let i = 0; i < tabs.length; i++) { + tabs[i].style.display = 'none'; + } + let tabItems = document.getElementsByClassName('tab-item'); + for (let i = 0; i < tabItems.length; i++) { + tabItems[i].classList.remove('active'); + } + this.classList.add('active'); + + let id = this.href.split('#')[1]; + if (id === 'files') { + initFiles(); + } else { + initDirectories(); + } + + let el = document.getElementById(id); + if (el.style.display === 'block') { + el.style.display = 'none'; + } else { + el.style.display = 'block'; + } + }); + + document.addEventListener("DOMContentLoaded", function(event) { + initFiles(true); + + let el = document.getElementById(window.location.hash.replace(/^#|L\d+$/g, '')); + if (el) { + initFileContent(el); + el.style.display = 'block'; + } + }); + })(); +</script> </body> </html> diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php index 1ae8d16f..741a37d9 100644 --- a/src/Framework/Assert.php +++ b/src/Framework/Assert.php @@ -442,6 +442,40 @@ public static function matchFile(string $file, $actual, string $description = nu } + /** + * Compares value with a previously created snapshot. + */ + public static function snapshot(string $snapshotName, $actual, string $description = null): void + { + self::$counter++; + + $snapshot = new Snapshot($snapshotName); + if (!$snapshot->exists()) { + if (!$snapshot->canUpdate()) { + self::fail("Missing snapshot '$snapshotName', use --update-snapshots option to generate it."); + } + + $snapshot->update($actual); + } + + $expected = $snapshot->read(); + if ($expected !== $actual) { + if (!$snapshot->canUpdate()) { + self::fail( + self::describe( + "%1 should be %2 in snapshot '$snapshotName'", + $description + ), + $actual, + $expected + ); + } + + $snapshot->update($actual); + } + } + + /** * Assertion that fails. */ diff --git a/src/Framework/Environment.php b/src/Framework/Environment.php index 150b14b4..759dc1bf 100644 --- a/src/Framework/Environment.php +++ b/src/Framework/Environment.php @@ -30,6 +30,9 @@ class Environment /** Thread number when run tests in multi threads */ public const THREAD = 'NETTE_TESTER_THREAD'; + /** Should Tester update snapshots? */ + public const UPDATE_SNAPSHOTS = 'NETTE_TESTER_UPDATE_SNAPSHOTS'; + /** @var bool */ public static $checkAssertions = false; @@ -126,6 +129,11 @@ public static function setupErrors(): void self::removeOutputBuffers(); echo "\n", Dumper::color('white/red', "Fatal error: $error[message] in $error[file] on line $error[line]"), "\n"; } + } elseif (getenv(self::UPDATE_SNAPSHOTS) && Snapshot::$updatedSnapshots) { + self::removeOutputBuffers(); + echo "\nThe following snapshots were updated, please make sure they are correct:\n" + . implode("\n", Snapshot::$updatedSnapshots) . "\n"; + exit(Runner\Job::CODE_FAIL); } elseif (self::$checkAssertions && !Assert::$counter) { self::removeOutputBuffers(); echo "\n", Dumper::color('white/red', 'Error: This test forgets to execute an assertion.'), "\n"; diff --git a/src/Framework/Snapshot.php b/src/Framework/Snapshot.php new file mode 100644 index 00000000..c9950226 --- /dev/null +++ b/src/Framework/Snapshot.php @@ -0,0 +1,102 @@ +<?php + +/** + * This file is part of the Nette Tester. + * Copyright (c) 2009 David Grudl (https://davidgrudl.com) + */ + +declare(strict_types=1); + +namespace Tester; + + +/** + * Snapshot of a tested value. + * @internal + */ +class Snapshot +{ + /** @var string */ + public static $snapshotDir = 'snapshots'; + + /** @var string[] */ + public static $updatedSnapshots = []; + + /** @var string[] */ + private static $usedNames = []; + + /** @var string */ + private $name; + + + public function __construct(string $name) + { + if (!preg_match('/^[a-zA-Z0-9-_]+$/', $name)) { + throw new \Exception("Invalid snapshot name '$name'. Only alphanumeric characters, dash and underscore are allowed."); + } + + if (in_array($name, self::$usedNames, true)) { + throw new \Exception("Snapshot '$name' was already asserted, please use a different name."); + } + + $this->name = self::$usedNames[] = $name; + } + + + public function exists(): bool + { + return file_exists($this->getSnapshotFile()); + } + + + public function read() + { + $snapshotFile = $this->getSnapshotFile(); + set_error_handler(function ($errno, $errstr) use ($snapshotFile) { + throw new \Exception("Unable to read snapshot file '$snapshotFile': $errstr"); + }); + + $snapshotContents = include $snapshotFile; + + restore_error_handler(); + return $snapshotContents; + } + + + public function canUpdate(): bool + { + return (bool) getenv(Environment::UPDATE_SNAPSHOTS); + } + + + public function update($value): void + { + if (!$this->canUpdate()) { + throw new \Exception('Cannot update snapshot. Please run tests again with --update-snapshots.'); + } + + $snapshotFile = $this->getSnapshotFile(); + $snapshotDirectory = dirname($snapshotFile); + if (!is_dir($snapshotDirectory) && !mkdir($snapshotDirectory)) { + throw new \Exception("Unable to create snapshot directory '$snapshotDirectory'."); + } + + $snapshotContents = '<?php return ' . var_export($value, true) . ';' . PHP_EOL; + if (file_put_contents($snapshotFile, $snapshotContents) === false) { + throw new \Exception("Unable to write snapshot file '$snapshotFile'."); + } + + self::$updatedSnapshots[] = $snapshotFile; + } + + + private function getSnapshotFile(): string + { + $testFile = $_SERVER['argv'][0]; + $path = self::$snapshotDir . DIRECTORY_SEPARATOR . pathinfo($testFile, PATHINFO_FILENAME) . '.' . $this->name . '.phps'; + if (!preg_match('#/|\w:#A', self::$snapshotDir)) { + $path = dirname($testFile) . DIRECTORY_SEPARATOR . $path; + } + return $path; + } +} diff --git a/src/Runner/CliTester.php b/src/Runner/CliTester.php index cbf96033..ccd2e6d2 100644 --- a/src/Runner/CliTester.php +++ b/src/Runner/CliTester.php @@ -66,6 +66,9 @@ public function run(): ?int if ($this->options['--coverage']) { $coverageFile = $this->prepareCodeCoverage($runner); } + if ($this->options['--update-snapshots']) { + $runner->setEnvironmentVariable(Environment::UPDATE_SNAPSHOTS, '1'); + } if ($this->options['-o'] !== null) { ob_clean(); @@ -118,6 +121,7 @@ private function loadOptions(): CommandLine --colors [1|0] Enable or disable colors. --coverage <path> Generate code coverage report to file. --coverage-src <path> Path to source code. + --update-snapshots Create or update snapshot files. -h | --help This help. XX @@ -236,8 +240,14 @@ private function prepareCodeCoverage(Runner $runner): string file_put_contents($this->options['--coverage'], ''); $file = realpath($this->options['--coverage']); + [$engine, $version] = reset($engines); + $runner->setEnvironmentVariable(Environment::COVERAGE, $file); - $runner->setEnvironmentVariable(Environment::COVERAGE_ENGINE, $engine = reset($engines)); + $runner->setEnvironmentVariable(Environment::COVERAGE_ENGINE, $engine); + + if ($engine === CodeCoverage\Collector::ENGINE_XDEBUG && version_compare($version, '3.0.0', '>=')) { + $runner->addPhpIniOption('xdebug.mode', ltrim(ini_get('xdebug.mode') . ',coverage', ',')); + } if ($engine === CodeCoverage\Collector::ENGINE_PCOV && count($this->options['--coverage-src'])) { $runner->addPhpIniOption('pcov.directory', Helpers::findCommonDirectory($this->options['--coverage-src'])); diff --git a/src/Runner/info.php b/src/Runner/info.php index c00d4521..a2c2b012 100644 --- a/src/Runner/info.php +++ b/src/Runner/info.php @@ -36,7 +36,9 @@ 'PHP version' . ($isPhpDbg ? '; PHPDBG version' : '') => "$info->version ($info->sapi)" . ($isPhpDbg ? "; $info->phpDbgVersion" : ''), 'Loaded php.ini files' => count($info->iniFiles) ? implode(', ', $info->iniFiles) : '(none)', - 'Code coverage engines' => count($info->codeCoverageEngines) ? implode(', ', $info->codeCoverageEngines) : '(not available)', + 'Code coverage engines' => count($info->codeCoverageEngines) + ? implode(', ', array_map(function (array $engineInfo) { return sprintf('%s (%s)', ...$engineInfo); }, $info->codeCoverageEngines)) + : '(not available)', 'PHP temporary directory' => $info->tempDir == '' ? '(empty)' : $info->tempDir, 'Loaded extensions' => count($info->extensions) ? implode(', ', $info->extensions) : '(none)', ] as $title => $value) { diff --git a/src/bootstrap.php b/src/bootstrap.php index 065a4c7e..b18444e1 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -16,6 +16,7 @@ require __DIR__ . '/Framework/TestCase.php'; require __DIR__ . '/Framework/FileMutator.php'; require __DIR__ . '/Framework/Expect.php'; +require __DIR__ . '/Framework/Snapshot.php'; require __DIR__ . '/CodeCoverage/Collector.php'; require __DIR__ . '/Runner/Job.php'; diff --git a/tests/CodeCoverage/Collector.phpt b/tests/CodeCoverage/Collector.phpt index a6e4d3bb..29074d60 100644 --- a/tests/CodeCoverage/Collector.phpt +++ b/tests/CodeCoverage/Collector.phpt @@ -9,13 +9,21 @@ use Tester\FileMock; require __DIR__ . '/../bootstrap.php'; -$engines = array_filter(CodeCoverage\Collector::detectEngines(), function (string $engine) { +$engines = array_filter(CodeCoverage\Collector::detectEngines(), function (array $engineInfo) { + [$engine] = $engineInfo; return $engine !== CodeCoverage\Collector::ENGINE_PCOV; // PCOV needs system pcov.directory INI to be set }); if (count($engines) < 1) { Tester\Environment::skip('Requires Xdebug or PHPDB SAPI.'); } -$engine = reset($engines); +[$engine, $version] = reset($engines); + +if ($engine === CodeCoverage\Collector::ENGINE_XDEBUG + && version_compare($version, '3.0.0', '>=') + && strpos(ini_get('xdebug.mode'), 'coverage') === false +) { + Tester\Environment::skip('Requires xdebug.mode=coverage with Xdebug 3.'); +} if (CodeCoverage\Collector::isStarted()) { Tester\Environment::skip('Requires running without --coverage.'); diff --git a/tests/Framework/.gitignore b/tests/Framework/.gitignore new file mode 100644 index 00000000..373f5f3d --- /dev/null +++ b/tests/Framework/.gitignore @@ -0,0 +1 @@ +snapshots/ diff --git a/tests/Framework/Assert.snapshot.phpt b/tests/Framework/Assert.snapshot.phpt new file mode 100644 index 00000000..6e000212 --- /dev/null +++ b/tests/Framework/Assert.snapshot.phpt @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +use Tester\Assert; +use Tester\AssertException; +use Tester\Snapshot; + +require __DIR__ . '/../bootstrap.php'; + +Snapshot::$snapshotDir = __DIR__ . DIRECTORY_SEPARATOR . 'fixtures'; + +Assert::snapshot('existingSnapshot', ['answer' => 42]); + +Assert::exception(function () { + Assert::snapshot('invalid / name', ['answer' => 42]); +}, \Exception::class, "Invalid snapshot name 'invalid / name'. Only alphanumeric characters, dash and underscore are allowed."); + +Assert::exception(function () { + Assert::snapshot('existingSnapshot', ['answer' => 42]); +}, \Exception::class, "Snapshot 'existingSnapshot' was already asserted, please use a different name."); + +Assert::exception(function () { + Assert::snapshot('anotherSnapshot', ['answer' => 43]); +}, AssertException::class, "%a% should be %a% in snapshot 'anotherSnapshot'"); + +Assert::exception(function () { + Assert::snapshot('nonExistingSnapshot', 'value'); +}, AssertException::class, "Missing snapshot 'nonExistingSnapshot', use --update-snapshots option to generate it."); diff --git a/tests/Framework/Assert.snapshot.update.phpt b/tests/Framework/Assert.snapshot.update.phpt new file mode 100644 index 00000000..d35e723c --- /dev/null +++ b/tests/Framework/Assert.snapshot.update.phpt @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +use Tester\Assert; +use Tester\Environment; +use Tester\Helpers; +use Tester\Snapshot; + +require __DIR__ . '/../bootstrap.php'; + +// https://bugs.php.net/bug.php?id=76801 +// fixed by https://github.com/php/php-src/pull/3965 in PHP 7.2.18 +if ( + strncasecmp(PHP_OS, 'win', 3) === 0 + && strpos(PHP_BINARY, 'phpdbg') !== false + && version_compare(PHP_VERSION, '7.2.18') < 0 +) { + Environment::skip('There is a bug in PHP :('); +} + +putenv(Environment::UPDATE_SNAPSHOTS . '=1'); +Snapshot::$snapshotDir = __DIR__ . DIRECTORY_SEPARATOR . 'snapshots'; +Helpers::purge(Snapshot::$snapshotDir); + +// newly created + +Assert::false(file_exists(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.newSnapshot.phps')); +Assert::snapshot('newSnapshot', ['answer' => 42]); +Assert::true(file_exists(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.newSnapshot.phps')); +Assert::contains('42', file_get_contents(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.newSnapshot.phps')); + +// existing + +file_put_contents( + Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps', + '<?php return array(\'answer\' => 43);' . PHP_EOL +); + +Assert::true(file_exists(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps')); +Assert::snapshot('updatedSnapshot', ['answer' => 42]); +Assert::true(file_exists(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps')); +Assert::contains('42', file_get_contents(Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps')); + +// Snapshot::$updatedSnapshots + +Assert::same([ + Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.newSnapshot.phps', + Snapshot::$snapshotDir . DIRECTORY_SEPARATOR . 'Assert.snapshot.update.updatedSnapshot.phps', +], Snapshot::$updatedSnapshots); + +// reset the env variable so that the test does not fail due to updated snapshots +putenv(Environment::UPDATE_SNAPSHOTS . '=0'); diff --git a/tests/Framework/fixtures/Assert.snapshot.anotherSnapshot.phps b/tests/Framework/fixtures/Assert.snapshot.anotherSnapshot.phps new file mode 100644 index 00000000..a7d8f5b1 --- /dev/null +++ b/tests/Framework/fixtures/Assert.snapshot.anotherSnapshot.phps @@ -0,0 +1 @@ +<?php return ['answer' => 42]; diff --git a/tests/Framework/fixtures/Assert.snapshot.existingSnapshot.phps b/tests/Framework/fixtures/Assert.snapshot.existingSnapshot.phps new file mode 100644 index 00000000..a7d8f5b1 --- /dev/null +++ b/tests/Framework/fixtures/Assert.snapshot.existingSnapshot.phps @@ -0,0 +1 @@ +<?php return ['answer' => 42]; diff --git a/tests/Runner/PhpInterpreter.phpt b/tests/Runner/PhpInterpreter.phpt index 13e89b92..2516d628 100644 --- a/tests/Runner/PhpInterpreter.phpt +++ b/tests/Runner/PhpInterpreter.phpt @@ -18,15 +18,15 @@ Assert::same(strpos(PHP_SAPI, 'cgi') !== false, $interpreter->isCgi()); $count = 0; $engines = $interpreter->getCodeCoverageEngines(); if (defined('PHPDBG_VERSION')) { - Assert::contains(Tester\CodeCoverage\Collector::ENGINE_PHPDBG, $engines); + Assert::contains([Tester\CodeCoverage\Collector::ENGINE_PHPDBG, PHPDBG_VERSION], $engines); $count++; } if (extension_loaded('xdebug')) { - Assert::contains(Tester\CodeCoverage\Collector::ENGINE_XDEBUG, $engines); + Assert::contains([Tester\CodeCoverage\Collector::ENGINE_XDEBUG, phpversion('xdebug')], $engines); $count++; } if (extension_loaded('pcov')) { - Assert::contains(Tester\CodeCoverage\Collector::ENGINE_PCOV, $engines); + Assert::contains([Tester\CodeCoverage\Collector::ENGINE_PCOV, phpversion('pcov')], $engines); $count++; } Assert::count($count, $engines); diff --git a/tests/Runner/Runner.snapshots.phpt b/tests/Runner/Runner.snapshots.phpt new file mode 100644 index 00000000..2036cb69 --- /dev/null +++ b/tests/Runner/Runner.snapshots.phpt @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +use Tester\Assert; +use Tester\Dumper; +use Tester\Runner\Test; + +require __DIR__ . '/../bootstrap.php'; +require __DIR__ . '/../../src/Runner/OutputHandler.php'; +require __DIR__ . '/../../src/Runner/Test.php'; +require __DIR__ . '/../../src/Runner/TestHandler.php'; +require __DIR__ . '/../../src/Runner/Runner.php'; + + +class Logger implements Tester\Runner\OutputHandler +{ + public $results = []; + + + public function prepare(Test $test): void + { + } + + + public function finish(Test $test): void + { + $this->results[basename($test->getFile())] = [$test->getResult(), $test->message]; + } + + + public function begin(): void + { + } + + + public function end(): void + { + } +} + + +Tester\Helpers::purge(__DIR__ . '/snapshots/snapshots'); + + +// first run, without update -> fail + +$runner = new Tester\Runner\Runner(createInterpreter()); +$runner->paths[] = __DIR__ . '/snapshots/*.phptx'; +$runner->outputHandlers[] = $logger = new Logger; +$runner->run(); + +Assert::same(Test::FAILED, $logger->results['update-snapshots.phptx'][0]); +Assert::match( + "Failed: Missing snapshot '%a%', use --update-snapshots option to generate it.\n%A%", + trim(Dumper::removeColors($logger->results['update-snapshots.phptx'][1])) +); + +// second run, with update -> fail + +$runner = new Tester\Runner\Runner(createInterpreter()); +$runner->paths[] = __DIR__ . '/snapshots/*.phptx'; +$runner->outputHandlers[] = $logger = new Logger; +$runner->setEnvironmentVariable(Tester\Environment::UPDATE_SNAPSHOTS, '1'); +$runner->run(); + +Assert::same(Test::FAILED, $logger->results['update-snapshots.phptx'][0]); +Assert::match( + "The following snapshots were updated, please make sure they are correct:\n%a%.snapshot.phps", + trim(Dumper::removeColors($logger->results['update-snapshots.phptx'][1])) +); + +// third run, without update -> pass + +$runner = new Tester\Runner\Runner(createInterpreter()); +$runner->paths[] = __DIR__ . '/snapshots/*.phptx'; +$runner->outputHandlers[] = $logger = new Logger; +$runner->run(); + +Assert::same(Test::PASSED, $logger->results['update-snapshots.phptx'][0]); diff --git a/tests/Runner/snapshots/.gitignore b/tests/Runner/snapshots/.gitignore new file mode 100644 index 00000000..373f5f3d --- /dev/null +++ b/tests/Runner/snapshots/.gitignore @@ -0,0 +1 @@ +snapshots/ diff --git a/tests/Runner/snapshots/update-snapshots.phptx b/tests/Runner/snapshots/update-snapshots.phptx new file mode 100644 index 00000000..0e7a2c2d --- /dev/null +++ b/tests/Runner/snapshots/update-snapshots.phptx @@ -0,0 +1,7 @@ +<?php + +use Tester\Assert; + +require __DIR__ . '/../../bootstrap.php'; + +Assert::snapshot('snapshot', 'snapshot value');