diff --git a/src/Report/Html/ControlFlowGraph.php b/src/Report/Html/ControlFlowGraph.php new file mode 100644 index 000000000..b8b7170b6 --- /dev/null +++ b/src/Report/Html/ControlFlowGraph.php @@ -0,0 +1,263 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace SebastianBergmann\CodeCoverage\Report\Html; + +use function count; +use function fclose; +use function fwrite; +use function implode; +use function max; +use function min; +use function preg_replace; +use function proc_close; +use function proc_open; +use function sprintf; +use function stream_get_contents; +use function stream_set_blocking; +use function strlen; +use function substr; +use SebastianBergmann\CodeCoverage\Data\ProcessedFunctionCoverageData; +use SebastianBergmann\CodeCoverage\Data\ProcessedPathCoverageData; + +/** + * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage + */ +final class ControlFlowGraph +{ + public const int XDEBUG_EXIT_BRANCH = 2147483645; + private ?bool $dotAvailable = null; + private readonly Colors $colors; + + public function __construct(Colors $colors) + { + $this->colors = $colors; + } + + /** + * @param null|array $paths + */ + public function renderSvg(ProcessedFunctionCoverageData $methodData, ?array $paths = null): string + { + $dot = $this->generateDot($methodData, $paths); + + return $this->dotToSvg($dot); + } + + /** + * @param null|array $paths + */ + private function generateDot(ProcessedFunctionCoverageData $methodData, ?array $paths = null): string + { + $coveredFill = $this->colors->successLow(); + $coveredBorder = $this->colors->successBar(); + $uncoveredFill = $this->colors->danger(); + $uncoveredEdge = $this->colors->dangerBar(); + + $dot = "digraph {\n"; + $dot .= " rankdir=TB;\n"; + $dot .= ' node [shape=box, style=filled, fontname="sans-serif", fontsize=11];' . "\n"; + $dot .= ' entry [label="entry", shape=oval, style=filled, fillcolor="#f5f5f5", color="#999999"];' . "\n"; + + $hasExit = false; + $firstBranchId = null; + + foreach ($methodData->branches as $branchId => $branch) { + if ($firstBranchId === null) { + $firstBranchId = $branchId; + } + + foreach ($branch->out as $destBranchId) { + if ($destBranchId === self::XDEBUG_EXIT_BRANCH) { + $hasExit = true; + } + } + + $lineStart = min($branch->line_start, $branch->line_end); + $lineEnd = max($branch->line_start, $branch->line_end); + $label = $lineStart === $lineEnd + ? sprintf('L%d', $lineStart) + : sprintf('L%d-L%d', $lineStart, $lineEnd); + + if ($branch->hit !== []) { + $fillColor = $coveredFill; + $color = $coveredBorder; + } else { + $fillColor = $uncoveredFill; + $color = $uncoveredEdge; + } + + $dot .= sprintf( + ' b%d [label="%s", fillcolor="%s", color="%s"];' . "\n", + $branchId, + $label, + $fillColor, + $color, + ); + } + + if ($hasExit) { + $dot .= ' exit [label="exit", shape=oval, style=filled, fillcolor="#f5f5f5", color="#999999"];' . "\n"; + } + + if ($firstBranchId !== null) { + $dot .= sprintf(" entry -> b%d;\n", $firstBranchId); + } + + $edgePathClasses = $this->buildEdgePathClasses($methodData, $paths); + + foreach ($methodData->branches as $branchId => $branch) { + foreach ($branch->out as $edgeIndex => $destBranchId) { + $destNode = $destBranchId === self::XDEBUG_EXIT_BRANCH + ? 'exit' + : sprintf('b%d', $destBranchId); + + $edgeHit = isset($branch->out_hit[$edgeIndex]) && $branch->out_hit[$edgeIndex] > 0; + $color = $edgeHit ? $coveredBorder : $uncoveredEdge; + + $edgeKey = $destBranchId === self::XDEBUG_EXIT_BRANCH + ? $branchId . '-exit' + : $branchId . '-' . $destBranchId; + + $attrs = sprintf('color="%s"', $color); + $attrs .= sprintf(', id="edge-%s"', $edgeKey); + + if (isset($edgePathClasses[$edgeKey])) { + $attrs .= sprintf(', class="%s"', implode(' ', $edgePathClasses[$edgeKey])); + } + + $dot .= sprintf( + " b%d -> %s [%s];\n", + $branchId, + $destNode, + $attrs, + ); + } + } + + $dot .= "}\n"; + + return $dot; + } + + /** + * @param null|array $paths + * + * @return array> + */ + private function buildEdgePathClasses(ProcessedFunctionCoverageData $methodData, ?array $paths): array + { + $edgePathClasses = []; + + if ($paths === null) { + return $edgePathClasses; + } + + $pathIndex = 0; + + foreach ($paths as $path) { + $branchIds = $path->path; + + for ($i = 0; $i < count($branchIds) - 1; $i++) { + $edgeKey = $branchIds[$i] . '-' . $branchIds[$i + 1]; + + if (!isset($edgePathClasses[$edgeKey])) { + $edgePathClasses[$edgeKey] = []; + } + + $edgePathClasses[$edgeKey][] = 'path-' . $pathIndex; + } + + $lastBranchId = $branchIds[count($branchIds) - 1]; + + if (isset($methodData->branches[$lastBranchId])) { + foreach ($methodData->branches[$lastBranchId]->out as $dest) { + if ($dest === self::XDEBUG_EXIT_BRANCH) { + $edgeKey = $lastBranchId . '-exit'; + + if (!isset($edgePathClasses[$edgeKey])) { + $edgePathClasses[$edgeKey] = []; + } + + $edgePathClasses[$edgeKey][] = 'path-' . $pathIndex; + } + } + } + + $pathIndex++; + } + + return $edgePathClasses; + } + + private function dotToSvg(string $dot): string + { + if ($this->dotAvailable === false) { + return ''; + } + + $descriptorSpec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = @proc_open('dot -Tsvg', $descriptorSpec, $pipes); + + if ($process === false) { + $this->dotAvailable = false; + + return ''; + } + + // Use non-blocking I/O to avoid deadlock when dot's output + // buffer fills up before we finish writing the input + stream_set_blocking($pipes[1], false); + + $written = 0; + $length = strlen($dot); + $svg = ''; + + while ($written < $length) { + $chunk = @fwrite($pipes[0], substr($dot, $written)); + + if ($chunk === false) { + break; + } + + $written += $chunk; + + // Drain available output to prevent pipe buffer deadlock + $svg .= stream_get_contents($pipes[1]); + } + + fclose($pipes[0]); + + // Read remaining output + stream_set_blocking($pipes[1], true); + $svg .= stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0 || $svg === '') { + $this->dotAvailable = false; + + return ''; + } + + $this->dotAvailable = true; + + // Strip XML declaration and DOCTYPE, keep only the element + return preg_replace('/^.*?(thresholds, $hasBranchCoverage, + $this->colors, ); $directory->render($report, $target . 'index.html'); diff --git a/src/Report/Html/Renderer/File.php b/src/Report/Html/Renderer/File.php index 6dbbc5763..92c029230 100644 --- a/src/Report/Html/Renderer/File.php +++ b/src/Report/Html/Renderer/File.php @@ -93,10 +93,13 @@ use function explode; use function file_get_contents; use function htmlspecialchars; +use function implode; use function is_string; +use function json_encode; use function ksort; +use function max; +use function min; use function range; -use function sort; use function sprintf; use function str_ends_with; use function str_replace; @@ -111,6 +114,7 @@ use SebastianBergmann\CodeCoverage\Data\ProcessedTraitType; use SebastianBergmann\CodeCoverage\FileCouldNotBeWrittenException; use SebastianBergmann\CodeCoverage\Node\File as FileNode; +use SebastianBergmann\CodeCoverage\Report\Thresholds; use SebastianBergmann\CodeCoverage\Util\Percentage; use SebastianBergmann\Template\Exception; use SebastianBergmann\Template\Template; @@ -201,7 +205,16 @@ final class File extends Renderer /** * @var array> */ - private static array $formattedSourceCache = []; + private static array $formattedSourceCache = []; + private ?ControlFlowGraph $controlFlowGraph = null; + private readonly Colors $colors; + + public function __construct(string $templatePath, string $generator, string $date, Thresholds $thresholds, bool $hasBranchCoverage, Colors $colors) + { + parent::__construct($templatePath, $generator, $date, $thresholds, $hasBranchCoverage); + + $this->colors = $colors; + } public function render(FileNode $node, string $file): void { @@ -218,6 +231,12 @@ public function render(FileNode $node, string $file): void ], ); + if ($this->hasBranchCoverage) { + $template->setVar( + ['tabs' => $this->renderViewTabs($node->name(), 'line')], + ); + } + try { $template->renderTo($file . '.html'); } catch (Exception $e) { @@ -231,6 +250,7 @@ public function render(FileNode $node, string $file): void if ($this->hasBranchCoverage) { $template->setVar( [ + 'tabs' => $this->renderViewTabs($node->name(), 'branch'), 'items' => $this->renderItems($node), 'lines' => $this->renderSourceWithBranchCoverage($node), 'legend' => '

Fully coveredPartially coveredNot covered

', @@ -250,6 +270,7 @@ public function render(FileNode $node, string $file): void $template->setVar( [ + 'tabs' => $this->renderViewTabs($node->name(), 'path'), 'items' => $this->renderItems($node), 'lines' => $this->renderSourceWithPathCoverage($node), 'legend' => '

Fully coveredPartially coveredNot covered

', @@ -589,6 +610,34 @@ private function renderSourceWithLineCoverage(FileNode $node): string return $linesTemplate->render(); } + private function renderViewTabs(string $fileName, string $activeView): string + { + $tabs = [ + 'line' => ['href' => $fileName . '.html', 'label' => 'Line Coverage'], + 'branch' => ['href' => $fileName . '_branch.html', 'label' => 'Branch Coverage'], + 'path' => ['href' => $fileName . '_path.html', 'label' => 'Path Coverage'], + ]; + + $html = ' '; + + return $html; + } + private function renderSourceWithBranchCoverage(FileNode $node): string { $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); @@ -598,7 +647,8 @@ private function renderSourceWithBranchCoverage(FileNode $node): string $testData = $node->testData(); $codeLines = $this->loadFile($node->pathAsString()); - $lineData = []; + $lineData = []; + $decisionPointData = []; foreach (array_keys($codeLines) as $line) { $lineData[$line + 1] = [ @@ -611,7 +661,25 @@ private function renderSourceWithBranchCoverage(FileNode $node): string /** @var ProcessedFunctionCoverageData $method */ foreach ($functionCoverageData as $method) { /** @var ProcessedBranchCoverageData $branch */ - foreach ($method->branches as $branch) { + foreach ($method->branches as $branchId => $branch) { + if (count($branch->out) > 1) { + $decisionLine = max($branch->line_start, $branch->line_end); + + if (isset($lineData[$decisionLine]) && !isset($decisionPointData[$decisionLine])) { + $targets = []; + + foreach ($branch->out as $targetBranchId) { + if (isset($method->branches[$targetBranchId])) { + $targets[] = $method->branches[$targetBranchId]->hit !== []; + } + } + + if (count($targets) > 1) { + $decisionPointData[$decisionLine] = $targets; + } + } + } + foreach (range($branch->line_start, $branch->line_end) as $line) { if (!isset($lineData[$line])) { // blank line at end of file is sometimes included here continue; @@ -635,6 +703,9 @@ private function renderSourceWithBranchCoverage(FileNode $node): string $trClass = ''; $popover = ''; + $coverageCount = ''; + $coverageCountClass = 'coverage-count'; + if ($lineData[$i]['includedInBranches'] > 0) { $lineCss = 'success'; @@ -644,6 +715,18 @@ private function renderSourceWithBranchCoverage(FileNode $node): string $lineCss = 'warning'; } + if (isset($decisionPointData[$i])) { + $markers = ''; + + foreach ($decisionPointData[$i] as $isHit) { + $markers .= $isHit + ? '' + : ''; + } + + $coverageCount = $markers; + } + $popoverContent = '
    '; if (count($lineData[$i]['tests']) === 1) { @@ -667,7 +750,7 @@ private function renderSourceWithBranchCoverage(FileNode $node): string ); } - $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover); + $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover, $coverageCount, $coverageCountClass); $i++; } @@ -723,6 +806,8 @@ private function renderSourceWithPathCoverage(FileNode $node): string foreach ($codeLines as $line) { $trClass = ''; $popover = ''; + $coverageCount = ''; + $coverageCountClass = 'coverage-count'; $includedInPathsCount = count(array_unique($lineData[$i]['includedInPaths'])); $includedInHitPathsCount = count(array_unique($lineData[$i]['includedInHitPaths'])); @@ -758,7 +843,7 @@ private function renderSourceWithPathCoverage(FileNode $node): string ); } - $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover); + $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover, $coverageCount, $coverageCountClass); $i++; } @@ -774,99 +859,91 @@ private function renderBranchStructure(FileNode $node): string $coverageData = $node->functionCoverageData(); $testData = $node->testData(); - $codeLines = $this->loadFile($node->pathAsString()); $branches = ''; ksort($coverageData); /** @var ProcessedFunctionCoverageData $methodData */ foreach ($coverageData as $methodName => $methodData) { - $branchStructure = ''; - - /** @var ProcessedBranchCoverageData $branch */ - foreach ($methodData->branches as $branch) { - $branchStructure .= $this->renderBranchLines($branch, $codeLines, $testData); - } - - if ($branchStructure !== '') { // don't show empty branches - $branches .= '
    ' . $this->abbreviateMethodName($methodName) . '
    ' . "\n"; - $branches .= $branchStructure; + if ($methodData->branches === []) { + continue; } - } - - $branchesTemplate->setVar(['branches' => $branches]); - - return $branchesTemplate->render(); - } - /** - * @param list $codeLines - */ - private function renderBranchLines(ProcessedBranchCoverageData $branch, array $codeLines, array $testData): string - { - $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); - $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); + $branchCount = count($methodData->branches); + $hitBranchCount = 0; - $lines = ''; + foreach ($methodData->branches as $branch) { + if ($branch->hit !== []) { + $hitBranchCount++; + } + } - $branchLines = range($branch->line_start, $branch->line_end); - sort($branchLines); // sometimes end_line < start_line + $badge = sprintf( + ' %d/%d', + $hitBranchCount === $branchCount ? 'success' : ($hitBranchCount === 0 ? 'danger' : 'warning'), + $hitBranchCount, + $branchCount, + ); - /** @var int $line */ - foreach ($branchLines as $line) { - if (!isset($codeLines[$line])) { // blank line at end of file is sometimes included here - continue; - } + $branches .= '
    ' . $this->abbreviateMethodName($methodName) . '' . $badge . '
    ' . "\n"; + $branches .= '' . "\n"; + $branches .= '' . "\n"; + $branches .= '' . "\n"; - $popoverContent = ''; - $popoverTitle = ''; + $branchIndex = 1; - $numTests = count($branch->hit); + /** @var ProcessedBranchCoverageData $branch */ + foreach ($methodData->branches as $branch) { + $lineStart = min($branch->line_start, $branch->line_end); + $lineEnd = max($branch->line_start, $branch->line_end); + $linesLabel = $lineStart === $lineEnd + ? sprintf('L%d', $lineStart, $lineStart) + : sprintf('L%dL%d', $lineStart, $lineStart, $lineEnd, $lineEnd); - if ($numTests === 0) { - $trClass = 'danger'; - } else { - $lineCss = 'covered-by-large-tests'; - $popoverContent = '
      '; + $numTests = count($branch->hit); - if ($numTests > 1) { - $popoverTitle = $numTests . ' tests cover this branch'; + if ($numTests === 0) { + $statusClass = 'danger'; + $statusLabel = 'Not covered'; + $testsLabel = '—'; } else { - $popoverTitle = '1 test covers this branch'; - } + $statusClass = 'success'; + $statusLabel = 'Covered'; - foreach ($branch->hit as $test) { - if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') { - $lineCss = 'covered-by-medium-tests'; - } elseif ($testData[$test]['size'] === 'small') { - $lineCss = 'covered-by-small-tests'; + $popoverContent = '
        '; + + foreach ($branch->hit as $test) { + $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); } - $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); - } - $trClass = $lineCss . ' popin'; - } + $popoverContent .= '
      '; - $popover = ''; + $testsLabel = sprintf( + '%s', + $numTests === 1 ? '1 test' : $numTests . ' tests', + htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + $numTests === 1 ? '1 test' : $numTests . ' tests', + ); + } - if ($popoverTitle !== '') { - $popover = sprintf( - ' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"', - $popoverTitle, - htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + $branches .= sprintf( + '
    ' . "\n", + $statusClass, + $branchIndex, + $linesLabel, + $statusLabel, + $testsLabel, ); - } - $lines .= $this->renderLine($singleLineTemplate, $line, $codeLines[$line - 1], $trClass, $popover); - } + $branchIndex++; + } - if ($lines === '') { - return ''; + $branches .= '
    #LinesStatusTests
    %d%s%s%s
    ' . "\n"; } - $linesTemplate->setVar(['lines' => $lines]); + $branchesTemplate->setVar(['branches' => $branches]); - return $linesTemplate->render(); + return $branchesTemplate->render(); } private function renderPathStructure(FileNode $node): string @@ -875,125 +952,159 @@ private function renderPathStructure(FileNode $node): string $coverageData = $node->functionCoverageData(); $testData = $node->testData(); - $codeLines = $this->loadFile($node->pathAsString()); $paths = ''; ksort($coverageData); /** @var ProcessedFunctionCoverageData $methodData */ foreach ($coverageData as $methodName => $methodData) { - $pathStructure = ''; - - if (count($methodData->paths) > 100) { - $pathStructure .= '

    ' . count($methodData->paths) . ' is too many paths to sensibly render, consider refactoring your code to bring this number down.

    '; - + if ($methodData->paths === []) { continue; } + $pathCount = count($methodData->paths); + $hitPathCount = 0; + foreach ($methodData->paths as $path) { - $pathStructure .= $this->renderPathLines($path, $methodData->branches, $codeLines, $testData); + if ($path->hit !== []) { + $hitPathCount++; + } } - if ($pathStructure !== '') { - $paths .= '
    ' . $this->abbreviateMethodName($methodName) . '
    ' . "\n"; - $paths .= $pathStructure; - } - } + $badge = sprintf( + ' %d/%d', + $hitPathCount === $pathCount ? 'success' : ($hitPathCount === 0 ? 'danger' : 'warning'), + $hitPathCount, + $pathCount, + ); - $pathsTemplate->setVar(['paths' => $paths]); + $paths .= '
    ' . $this->abbreviateMethodName($methodName) . '' . $badge . '
    ' . "\n"; - return $pathsTemplate->render(); - } + if ($pathCount > 100) { + $paths .= '
    ' . $pathCount . ' paths — click to expand' . "\n"; + } - /** - * @param array $branches - * @param list $codeLines - */ - private function renderPathLines(ProcessedPathCoverageData $path, array $branches, array $codeLines, array $testData): string - { - $linesTemplate = new Template($this->templatePath . 'lines.html.dist', '{{', '}}'); - $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}'); + $paths .= '' . "\n"; + $paths .= '' . "\n"; + $paths .= '' . "\n"; - $lines = ''; - $first = true; + $pathIndex = 1; - foreach ($path->path as $branchId) { - if ($first) { - $first = false; - } else { - $lines .= ' ' . "\n"; - } + foreach ($methodData->paths as $path) { + $branchLabels = []; - $branchLines = range($branches[$branchId]->line_start, $branches[$branchId]->line_end); - sort($branchLines); // sometimes end_line < start_line + foreach ($path->path as $branchId) { + $branch = $methodData->branches[$branchId]; + $branchLine = min($branch->line_start, $branch->line_end); - /** @var int $line */ - foreach ($branchLines as $line) { - if (!isset($codeLines[$line])) { // blank line at end of file is sometimes included here - continue; + $branchLabels[] = sprintf('L%d', $branchLine, $branchLine); } - $popoverContent = ''; - $popoverTitle = ''; + $branchesLabel = implode(' → ', $branchLabels); $numTests = count($path->hit); if ($numTests === 0) { - $trClass = 'danger'; + $statusClass = 'danger'; + $statusLabel = 'Not covered'; + $testsLabel = '—'; } else { - $lineCss = 'covered-by-large-tests'; - $popoverContent = '
      '; + $statusClass = 'success'; + $statusLabel = 'Covered'; - if ($numTests > 1) { - $popoverTitle = $numTests . ' tests cover this path'; - } else { - $popoverTitle = '1 test covers this path'; - } + $popoverContent = '
        '; foreach ($path->hit as $test) { - if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') { - $lineCss = 'covered-by-medium-tests'; - } elseif ($testData[$test]['size'] === 'small') { - $lineCss = 'covered-by-small-tests'; - } - $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); } - $trClass = $lineCss . ' popin'; - } - - $popover = ''; + $popoverContent .= '
      '; - if ($popoverTitle !== '') { - $popover = sprintf( - ' data-bs-title="%s" data-bs-content="%s" data-bs-placement="top" data-bs-html="true"', - $popoverTitle, + $testsLabel = sprintf( + '%s', + $numTests === 1 ? '1 test' : $numTests . ' tests', htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + $numTests === 1 ? '1 test' : $numTests . ' tests', ); } - $lines .= $this->renderLine($singleLineTemplate, $line, $codeLines[$line - 1], $trClass, $popover); + $paths .= sprintf( + '
    ' . "\n", + $statusClass, + $pathIndex - 1, + $pathIndex, + $branchesLabel, + $statusLabel, + $testsLabel, + ); + + $pathIndex++; } - } - if ($lines === '') { - return ''; + $paths .= '
    #BranchesStatusTests
     
    %d%s%s%s
    ' . "\n"; + + $pathsJson = []; + $pathIdx = 0; + + foreach ($methodData->paths as $path) { + $edges = []; + $branchIds = $path->path; + + for ($i = 0; $i < count($branchIds) - 1; $i++) { + $edges[] = $branchIds[$i] . '-' . $branchIds[$i + 1]; + } + + $lastBranchId = $branchIds[count($branchIds) - 1]; + + if (isset($methodData->branches[$lastBranchId])) { + foreach ($methodData->branches[$lastBranchId]->out as $dest) { + if ($dest === ControlFlowGraph::XDEBUG_EXIT_BRANCH) { + $edges[] = $lastBranchId . '-exit'; + } + } + } + + $pathsJson[$pathIdx] = $edges; + $pathIdx++; + } + + $svg = $this->controlFlowGraph()->renderSvg($methodData, $methodData->paths); + + $paths .= sprintf( + '
    %s
    ' . "\n", + htmlspecialchars(json_encode($pathsJson), self::HTML_SPECIAL_CHARS_FLAGS), + $svg, + ); + + if ($pathCount > 100) { + $paths .= '
    ' . "\n"; + } } - $linesTemplate->setVar(['lines' => $lines]); + $pathsTemplate->setVar(['paths' => $paths]); - return $linesTemplate->render(); + return $pathsTemplate->render(); + } + + private function controlFlowGraph(): ControlFlowGraph + { + if ($this->controlFlowGraph === null) { + $this->controlFlowGraph = new ControlFlowGraph($this->colors); + } + + return $this->controlFlowGraph; } - private function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover): string + private function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover, string $coverageCount = '', string $coverageCountClass = 'col-0'): string { $template->setVar( [ - 'lineNumber' => (string) $lineNumber, - 'lineContent' => $lineContent, - 'class' => $class, - 'popover' => $popover, + 'lineNumber' => (string) $lineNumber, + 'lineContent' => $lineContent, + 'class' => $class, + 'popover' => $popover, + 'coverageCount' => $coverageCount, + 'coverageCountClass' => $coverageCountClass, ], ); diff --git a/src/Report/Html/Renderer/Template/css/style.css b/src/Report/Html/Renderer/Template/css/style.css index db6d6e264..916612d37 100644 --- a/src/Report/Html/Renderer/Template/css/style.css +++ b/src/Report/Html/Renderer/Template/css/style.css @@ -171,6 +171,30 @@ td.codeLine { white-space: pre-wrap; } +td.coverage-count { + font-family: var(--bs-font-monospace); + font-size: 0.75em; + text-align: center; + vertical-align: middle; + white-space: nowrap; + width: 3.5em; + flex: 0 0 3.5em; +} + +td.col-0 { + display: none; +} + +.branch-hit { + color: var(--phpunit-success-bar); + font-weight: bold; +} + +.branch-miss { + color: var(--phpunit-danger-bar); + font-weight: bold; +} + td span.comment { color: var(--bs-secondary-color); } @@ -224,6 +248,19 @@ table + .structure-heading { padding-top: 0.5em; } +.structure-table { + width: auto; +} + +.structure-table td, .structure-table th { + white-space: nowrap; +} + +.structure-table .popin { + cursor: pointer; + text-decoration: underline dotted; +} + table#code td:first-of-type { padding-left: .75em; padding-right: .75em; @@ -272,3 +309,40 @@ table#code td:first-of-type a { .progress-bar.bg-danger { background-color: var(--phpunit-danger-bar) !important; } + +.cfg-graph { + margin: 1em 0; + padding: 0.5em; + border: 1px solid var(--bs-border-color); + border-radius: 4px; + overflow-x: auto; + background: var(--bs-body-bg); +} + +.cfg-graph svg { + max-width: 100%; + height: auto; + display: block; +} + +.cfg-graph .edge.highlighted path, +.cfg-graph .edge.highlighted polygon { + stroke: var(--bs-link-color); + stroke-width: 3px; + fill: var(--bs-link-color); +} + +.cfg-graph .node.highlighted polygon, +.cfg-graph .node.highlighted ellipse { + stroke: var(--bs-link-color); + stroke-width: 3px; +} + +.path-row { + cursor: pointer; +} + +.path-row.path-selected { + outline: 2px solid var(--bs-link-color); + outline-offset: -2px; +} diff --git a/src/Report/Html/Renderer/Template/file_branch.html.dist b/src/Report/Html/Renderer/Template/file_branch.html.dist index b8bcf3747..53cacc6c3 100644 --- a/src/Report/Html/Renderer/Template/file_branch.html.dist +++ b/src/Report/Html/Renderer/Template/file_branch.html.dist @@ -24,6 +24,7 @@
    +{{tabs}}
    diff --git a/src/Report/Html/Renderer/Template/js/file.js b/src/Report/Html/Renderer/Template/js/file.js index 124a8a18f..c4582c8be 100644 --- a/src/Report/Html/Renderer/Template/js/file.js +++ b/src/Report/Html/Renderer/Template/js/file.js @@ -17,37 +17,84 @@ $(function () { } }); - var $popovers = $('.popin > :first-child'); - $('.popin').on({ + var $linePopovers = $('tr.popin > :first-child'); + var $spanPopovers = $('span.popin[data-bs-content]'); + var $allPopovers = $linePopovers.add($spanPopovers); + + function hideAllExcept($except) { + $allPopovers.each(function () { + var $current = $(this); + if (!$except || !$current.is($except)) { + $current.popover('hide'); + } + }); + } + + $('tr.popin').on({ 'click.popover': function (event) { event.stopPropagation(); var $container = $(this).children().first(); - //Close all other popovers: - $popovers.each(function () { - var $current = $(this); - if (!$current.is($container)) { - $current.popover('hide'); - } - }); - - // Toggle this popover: + hideAllExcept($container); $container.popover('toggle'); }, }); + $spanPopovers.on({ + 'click.popover': function (event) { + event.stopPropagation(); + + var $span = $(this); + + hideAllExcept($span); + $span.popover('toggle'); + }, + }); + //Hide all popovers on outside click: $(document).click(function (event) { if ($(event.target).closest($('.popover')).length === 0) { - $popovers.popover('hide'); + hideAllExcept(null); } }); //Hide all popovers on escape: $(document).keyup(function (event) { if (event.key === 'Escape') { - $popovers.popover('hide'); + hideAllExcept(null); + } + }); + + // Path highlighting in control flow graphs + $(document).on('click', '.path-row', function () { + var $row = $(this); + var pathIndex = $row.data('path-index'); + var $table = $row.closest('table'); + var $graph = $table.nextAll('.cfg-graph').first(); + + if (!$graph.length) return; + + var pathsData = $graph.data('paths'); + + // Reset all highlights + $graph.find('.edge, .node').removeClass('highlighted'); + + // Toggle: if already selected, deselect + if ($row.hasClass('path-selected')) { + $('.path-row').removeClass('path-selected'); + return; + } + + $('.path-row').removeClass('path-selected'); + $row.addClass('path-selected'); + + if (!pathsData || !pathsData[pathIndex]) return; + + // Highlight edges for this path + var edges = pathsData[pathIndex]; + for (var i = 0; i < edges.length; i++) { + $graph.find('#edge-' + edges[i]).addClass('highlighted'); } }); }); diff --git a/src/Report/Html/Renderer/Template/line.html.dist b/src/Report/Html/Renderer/Template/line.html.dist index d7e055f63..82310b23f 100644 --- a/src/Report/Html/Renderer/Template/line.html.dist +++ b/src/Report/Html/Renderer/Template/line.html.dist @@ -1 +1 @@ - + diff --git a/tests/_files/Report/HTML/CoverageForBankAccount/BankAccount.php.html b/tests/_files/Report/HTML/CoverageForBankAccount/BankAccount.php.html index 9c503316d..b8c27a983 100644 --- a/tests/_files/Report/HTML/CoverageForBankAccount/BankAccount.php.html +++ b/tests/_files/Report/HTML/CoverageForBankAccount/BankAccount.php.html @@ -193,40 +193,40 @@
    {{lineNumber}}{{lineContent}}
    {{lineNumber}}{{coverageCount}}{{lineContent}}
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    1<?php
    2class BankAccount
    3{
    4    protected $balance = 0;
    5
    6    public function getBalance()
    7    {
    8        return $this->balance;
    9    }
    10
    11    protected function setBalance($balance)
    12    {
    13        if ($balance >= 0) {
    14            $this->balance = $balance;
    15        } else {
    16            throw new RuntimeException;
    17        }
    18    }
    19
    20    public function depositMoney($balance)
    21    {
    22        $this->setBalance($this->getBalance() + $balance);
    23
    24        return $this->getBalance();
    25    }
    26
    27    public function withdrawMoney($balance)
    28    {
    29        $this->setBalance($this->getBalance() - $balance);
    30
    31        return $this->getBalance();
    32        return $this->getBalance();
    33    }
    34}
    1<?php
    2class BankAccount
    3{
    4    protected $balance = 0;
    5
    6    public function getBalance()
    7    {
    8        return $this->balance;
    9    }
    10
    11    protected function setBalance($balance)
    12    {
    13        if ($balance >= 0) {
    14            $this->balance = $balance;
    15        } else {
    16            throw new RuntimeException;
    17        }
    18    }
    19
    20    public function depositMoney($balance)
    21    {
    22        $this->setBalance($this->getBalance() + $balance);
    23
    24        return $this->getBalance();
    25    }
    26
    27    public function withdrawMoney($balance)
    28    {
    29        $this->setBalance($this->getBalance() - $balance);
    30
    31        return $this->getBalance();
    32        return $this->getBalance();
    33    }
    34}
    diff --git a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html index ff821b35d..c2c438101 100644 --- a/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html +++ b/tests/_files/Report/HTML/CoverageForClassWithAnonymousFunction/source_with_class_and_anonymous_function.php.html @@ -127,25 +127,25 @@
    - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +
    1<?php
    2
    3class CoveredClassWithAnonymousFunctionInStaticMethod
    4{
    5    public static function runAnonymous()
    6    {
    7        $filter = ['abc124', 'abc123', '123'];
    8
    9        array_walk(
    10            $filter,
    11            function (&$val, $key) {
    12                $val = preg_replace('|[^0-9]|', '', $val);
    13            }
    14        );
    15
    16        // Should be covered
    17        $extravar = true;
    18    }
    19}
    1<?php
    2
    3class CoveredClassWithAnonymousFunctionInStaticMethod
    4{
    5    public static function runAnonymous()
    6    {
    7        $filter = ['abc124', 'abc123', '123'];
    8
    9        array_walk(
    10            $filter,
    11            function (&$val, $key) {
    12                $val = preg_replace('|[^0-9]|', '', $val);
    13            }
    14        );
    15
    16        // Should be covered
    17        $extravar = true;
    18    }
    19}
    diff --git a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html index 5b61e5aed..60e613c47 100644 --- a/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html +++ b/tests/_files/Report/HTML/CoverageForFileWithIgnoredLines/source_with_ignore.php.html @@ -135,48 +135,48 @@
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    1<?php
    2if ($neverHappens) {
    3    // @codeCoverageIgnoreStart
    4    print '*';
    5    // @codeCoverageIgnoreEnd
    6}
    7
    8/**
    9 * @codeCoverageIgnore
    10 */
    11class Foo
    12{
    13    public function bar()
    14    {
    15    }
    16}
    17
    18class Bar
    19{
    20    /**
    21     * @codeCoverageIgnore
    22     */
    23    public function foo()
    24    {
    25    }
    26}
    27
    28function baz()
    29{
    30    print '*'; // @codeCoverageIgnore
    31}
    32
    33interface Bor
    34{
    35    public function foo();
    36}
    37
    38// @codeCoverageIgnoreStart
    39print '
    40Multiline
    41';
    42// @codeCoverageIgnoreEnd
    1<?php
    2if ($neverHappens) {
    3    // @codeCoverageIgnoreStart
    4    print '*';
    5    // @codeCoverageIgnoreEnd
    6}
    7
    8/**
    9 * @codeCoverageIgnore
    10 */
    11class Foo
    12{
    13    public function bar()
    14    {
    15    }
    16}
    17
    18class Bar
    19{
    20    /**
    21     * @codeCoverageIgnore
    22     */
    23    public function foo()
    24    {
    25    }
    26}
    27
    28function baz()
    29{
    30    print '*'; // @codeCoverageIgnore
    31}
    32
    33interface Bor
    34{
    35    public function foo();
    36}
    37
    38// @codeCoverageIgnoreStart
    39print '
    40Multiline
    41';
    42// @codeCoverageIgnoreEnd
    diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html index 681941ba2..9d4f6d523 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html @@ -26,6 +26,11 @@
    +
    diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html index 681941ba2..a2914f57c 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html @@ -26,6 +26,11 @@
    diff --git a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html index 681941ba2..6204f8417 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_path.html @@ -26,6 +26,11 @@
    diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html index 1446f796d..bcb5ce066 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php.html @@ -26,6 +26,11 @@
    @@ -147,24 +152,24 @@
    - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
    1<?php
    2/**
    3 * Represents foo.
    4 */
    5class Foo
    6{
    7}
    8
    9/**
    10 * @param mixed $bar
    11 */
    12function &foo($bar)
    13{
    14    $baz = function () {};
    15    $a   = true ? true : false;
    16    $b   = "{$a}";
    17    $c   = "${b}";
    18}
    1<?php
    2/**
    3 * Represents foo.
    4 */
    5class Foo
    6{
    7}
    8
    9/**
    10 * @param mixed $bar
    11 */
    12function &foo($bar)
    13{
    14    $baz = function () {};
    15    $a   = true ? true : false;
    16    $b   = "{$a}";
    17    $c   = "${b}";
    18}
    diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html index 590d2010b..80d94a9ce 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_branch.html @@ -26,6 +26,11 @@
    +
    @@ -147,24 +152,24 @@
    - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
    1<?php
    2/**
    3 * Represents foo.
    4 */
    5class Foo
    6{
    7}
    8
    9/**
    10 * @param mixed $bar
    11 */
    12function &foo($bar)
    13{
    14    $baz = function () {};
    15    $a   = true ? true : false;
    16    $b   = "{$a}";
    17    $c   = "${b}";
    18}
    1<?php
    2/**
    3 * Represents foo.
    4 */
    5class Foo
    6{
    7}
    8
    9/**
    10 * @param mixed $bar
    11 */
    12function &foo($bar)
    13{
    14    $baz = function () {};
    15    $a   = true ? true : false;
    16    $b   = "{$a}";
    17    $c   = "${b}";
    18}
    @@ -177,43 +182,27 @@

    Branches

    Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

    -
    foo
    - - - - - - - - -
    12function &foo($bar)
    13{
    14    $baz = function () {};
    15    $a   = true ? true : false;
    - - - - - -
    15    $a   = true ? true : false;
    - +
    foo 0/4
    +
    + - - - -
    #LinesStatusTests
    15    $a   = true ? true : false;
    - + + + + +
    1L12L15Not covered
    2L15Not covered
    3L15Not covered
    4L15L18Not covered
    +
    {closure:%se.php:14-14} 0/1
    + + - - - - - -
    #LinesStatusTests
    15    $a   = true ? true : false;
    16    $b   = "{$a}";
    17    $c   = "${b}";
    -
    {closure:%ssource_without_namespace.php:14-14}
    - + +
    1L14Not covered
    +
    {main} 1/1
    + + - - - -
    #LinesStatusTests
    14    $baz = function () {};
    +1L19Covered1 test +
    diff --git a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html index 640b60b1f..34d9e5248 100644 --- a/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html +++ b/tests/_files/Report/HTML/PathCoverageForSourceWithoutNamespace/source_without_namespace.php_path.html @@ -26,6 +26,11 @@
    +
    @@ -147,24 +152,24 @@
    - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
    1<?php
    2/**
    3 * Represents foo.
    4 */
    5class Foo
    6{
    7}
    8
    9/**
    10 * @param mixed $bar
    11 */
    12function &foo($bar)
    13{
    14    $baz = function () {};
    15    $a   = true ? true : false;
    16    $b   = "{$a}";
    17    $c   = "${b}";
    18}
    1<?php
    2/**
    3 * Represents foo.
    4 */
    5class Foo
    6{
    7}
    8
    9/**
    10 * @param mixed $bar
    11 */
    12function &foo($bar)
    13{
    14    $baz = function () {};
    15    $a   = true ? true : false;
    16    $b   = "{$a}";
    17    $c   = "${b}";
    18}
    @@ -177,44 +182,28 @@

    Paths

    Please also be aware that some paths may include implicit rather than explicit branches, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

    -
    foo
    - +
    foo 0/2
    +
    + - - - - - - - - - - - - -
    #BranchesStatusTests
    12function &foo($bar)
    13{
    14    $baz = function () {};
    15    $a   = true ? true : false;
     
    15    $a   = true ? true : false;
     
    15    $a   = true ? true : false;
    16    $b   = "{$a}";
    17    $c   = "${b}";
    - + + +
    1L12L15L15Not covered
    2L12L15L15Not covered
    +
    %A
    +
    {closure:%se.php:14-14} 0/1
    + + - - - - - - - - - - - - -
    #BranchesStatusTests
    12function &foo($bar)
    13{
    14    $baz = function () {};
    15    $a   = true ? true : false;
     
    15    $a   = true ? true : false;
     
    15    $a   = true ? true : false;
    16    $b   = "{$a}";
    17    $c   = "${b}";
    -
    {closure:%ssource_without_namespace.php:14-14}
    - + +
    1L14Not covered
    +
    %A
    +
    {main} 1/1
    + + - - - -
    #BranchesStatusTests
    14    $baz = function () {};
    +1L19Covered1 test + +
    %A