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 @@ -

Code coverage  %

- $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), [ + '' => "", + '' => '', + '
' => '
', + ]); + $jsonData[$currentId] = $data; +} ?> + +

+ Code coverage  % + sources have lines of code in files +

+ + + +
+
- class ? " class='$info->class'" : '' ?>> - - - - + + + + + + +
coverage ?> %
name ?>
+  % + +
+
+ path  +
+
+
+ +
+ + + + + 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'; + } + }); + })(); + 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 @@ +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 = '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 Generate code coverage report to file. --coverage-src 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 @@ + 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 @@ + 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', + ' 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 @@ + 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 @@ + 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 @@ +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 @@ +