diff --git a/src/Report/Html/ControlFlowGraph.php b/src/Report/Html/ControlFlowGraph.php new file mode 100644 index 000000000..f10b896f8 --- /dev/null +++ b/src/Report/Html/ControlFlowGraph.php @@ -0,0 +1,265 @@ + + * + * 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 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 +{ + /** + * Branch identifier used by Xdebug for the exit of a function (XDEBUG_BRANCH_EXIT in xdebug_branch_info.h). + */ + public const int XDEBUG_EXIT_BRANCH = 2147483645; + private ?bool $dotAvailable = null; + private readonly string $dotBinary; + + public function __construct(string $dotBinary = 'dot') + { + $this->dotBinary = $dotBinary; + } + + /** + * @param null|array $paths + */ + public function renderSvg(ProcessedFunctionCoverageData $methodData, ?array $paths = null): string + { + $dot = $this->generateDot($methodData, $paths); + + return $this->dotToSvg($dot); + } + + /** + * Generates the graph in Graphviz DOT format. + * + * Nodes and edges carry "covered" / "uncovered" classes instead of color + * literals; the report's stylesheet maps them to the configured color + * scheme in both light mode and dark mode. + * + * @param null|array $paths + */ + public function generateDot(ProcessedFunctionCoverageData $methodData, ?array $paths = null): string + { + $dot = "digraph {\n"; + $dot .= " rankdir=TB;\n"; + $dot .= " bgcolor=transparent;\n"; + $dot .= ' node [shape=box, style=filled, fontname="sans-serif", fontsize=11];' . "\n"; + $dot .= ' entry [label="entry", shape=oval, class="terminal"];' . "\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); + + $dot .= sprintf( + ' b%d [label="%s", class="%s"];' . "\n", + $branchId, + $label, + $branch->hit !== [] ? 'covered' : 'uncovered', + ); + } + + if ($hasExit) { + $dot .= ' exit [label="exit", shape=oval, class="terminal"];' . "\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; + + $edgeKey = $destBranchId === self::XDEBUG_EXIT_BRANCH + ? $branchId . '-exit' + : $branchId . '-' . $destBranchId; + + $classes = [$edgeHit ? 'covered' : 'uncovered']; + + if (isset($edgePathClasses[$edgeKey])) { + $classes = [...$classes, ...$edgePathClasses[$edgeKey]]; + } + + $dot .= sprintf( + ' b%d -> %s [id="edge-%s", class="%s"];' . "\n", + $branchId, + $destNode, + $edgeKey, + implode(' ', $classes), + ); + } + } + + $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) { + $previousBranchId = null; + $lastBranchId = null; + + foreach ($path->path as $branchId) { + if ($previousBranchId !== null) { + $edgeKey = $previousBranchId . '-' . $branchId; + + if (!isset($edgePathClasses[$edgeKey])) { + $edgePathClasses[$edgeKey] = []; + } + + $edgePathClasses[$edgeKey][] = 'path-' . $pathIndex; + } + + $previousBranchId = $branchId; + $lastBranchId = $branchId; + } + + if ($lastBranchId !== null && 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($this->dotBinary . ' -Tsvg', $descriptorSpec, $pipes); + + if ($process === false || !isset($pipes[0], $pipes[1], $pipes[2])) { + // @codeCoverageIgnoreStart + $this->dotAvailable = false; + + return ''; + // @codeCoverageIgnoreEnd + } + + // 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 || $chunk === 0) { + // @codeCoverageIgnoreStart + break; + // @codeCoverageIgnoreEnd + } + + $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('/^.*?(hasBranchCoverage) { + $template->setVar( + ['tabs' => $this->renderViewTabs($node->name(), 'line')], + ); + } + try { $template->renderTo($file . '.html'); } catch (Exception $e) { @@ -84,6 +104,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

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

Fully coveredPartially coveredNot covered

', @@ -470,6 +492,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', '{{', '}}'); @@ -479,7 +529,8 @@ private function renderSourceWithBranchCoverage(FileNode $node): string $testData = $node->testData(); $codeLines = $this->highlightedSourceFor($node); - $lineData = []; + $lineData = []; + $decisionPointData = []; foreach (array_keys($codeLines) as $line) { $lineData[$line + 1] = [ @@ -492,7 +543,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; @@ -516,6 +585,9 @@ private function renderSourceWithBranchCoverage(FileNode $node): string $trClass = ''; $popover = ''; + $coverageCount = ''; + $coverageCountClass = 'coverage-count'; + $currentLineData = $lineData[$i] ?? [ 'includedInBranches' => 0, 'includedInHitBranches' => 0, @@ -531,6 +603,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($currentLineData['tests']) === 1) { @@ -560,7 +644,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); } $linesTemplate->setVar(['lines' => $lines]); @@ -621,6 +705,9 @@ private function renderSourceWithPathCoverage(FileNode $node): string $trClass = ''; $popover = ''; + $coverageCount = ''; + $coverageCountClass = 'coverage-count'; + $currentLineData = $lineData[$i] ?? [ 'includedInPaths' => [], 'includedInHitPaths' => [], @@ -668,7 +755,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); } $linesTemplate->setVar(['lines' => $lines]); @@ -680,197 +767,175 @@ private function renderBranchStructure(FileNode $node): string { $branchesTemplate = new Template($this->templatePath . 'branches.html.dist', '{{', '}}'); - $coverageData = $node->functionCoverageData(); + $coverageData = $this->sortedByStartLine($node->functionCoverageData()); $testData = $node->testData(); - $codeLines = $this->highlightedSourceFor($node); $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 ($methodData->branches === []) { + continue; } - if ($branchStructure !== '') { // don't show empty branches - $branches .= '
    ' . $this->abbreviateMethodName($methodName) . '
    ' . "\n"; - $branches .= $branchStructure; - } - } + $branchCount = count($methodData->branches); + $hitBranchCount = 0; - $branchesTemplate->setVar(['branches' => $branches]); - - return $branchesTemplate->render(); - } + foreach ($methodData->branches as $branch) { + if ($branch->hit !== []) { + $hitBranchCount++; + } + } - /** - * @param list $codeLines - * @param array $testData - */ - 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', '{{', '}}'); + $badge = sprintf( + ' %d/%d', + $hitBranchCount === $branchCount ? 'success' : ($hitBranchCount === 0 ? 'danger' : 'warning'), + $hitBranchCount, + $branchCount, + ); - $lines = ''; + $branches .= '
    ' . $this->abbreviateMethodName($methodName) . '' . $badge . '
    ' . "\n"; + $branches .= '' . "\n"; + $branches .= '' . "\n"; + $branches .= '' . "\n"; - $branchLines = range($branch->line_start, $branch->line_end); - sort($branchLines); // sometimes end_line < start_line + $branchIndex = 1; - /** @var int $line */ - foreach ($branchLines as $line) { - if (!isset($codeLines[$line])) { // blank line at end of file is sometimes included here - continue; - } + /** @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); - $popoverContent = ''; - $popoverTitle = ''; + $numTests = count($branch->hit); - $numTests = count($branch->hit); + if ($numTests === 0) { + $statusClass = 'danger'; + $statusLabel = 'Not covered'; + $testsLabel = '—'; + } else { + $statusClass = 'success'; + $statusLabel = 'Covered'; - if ($numTests === 0) { - $trClass = 'danger'; - } else { - $lineCss = 'covered-by-large-tests'; - $popoverContent = '
      '; + $popoverContent = '
        '; - if ($numTests > 1) { - $popoverTitle = $numTests . ' tests cover this branch'; - } else { - $popoverTitle = '1 test covers this branch'; - } + foreach ($branch->hit as $test) { + if (!isset($testData[$test])) { + // @codeCoverageIgnoreStart + continue; + // @codeCoverageIgnoreEnd + } - foreach ($branch->hit as $test) { - if (!isset($testData[$test])) { - // @codeCoverageIgnoreStart - continue; - // @codeCoverageIgnoreEnd + $popoverContent .= $this->createPopoverContentForTest($test, $testData[$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 .= '
      '; - $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]); + $testsLabel = sprintf( + '%s', + $numTests === 1 ? '1 test' : $numTests . ' tests', + htmlspecialchars($popoverContent, self::HTML_SPECIAL_CHARS_FLAGS), + $numTests === 1 ? '1 test' : $numTests . ' tests', + ); } - $trClass = $lineCss . ' popin'; - } - - $popover = ''; - 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 { $pathsTemplate = new Template($this->templatePath . 'paths.html.dist', '{{', '}}'); - $coverageData = $node->functionCoverageData(); + $coverageData = $this->sortedByStartLine($node->functionCoverageData()); $testData = $node->testData(); - $codeLines = $this->highlightedSourceFor($node); $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(); - } + $renderedPaths = $methodData->paths; - /** - * @param array $branches - * @param list $codeLines - * @param array $testData - */ - 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', '{{', '}}'); + if ($pathCount > self::MAX_RENDERED_PATHS) { + $renderedPaths = array_slice($methodData->paths, 0, self::MAX_RENDERED_PATHS, true); - $lines = ''; - $first = true; - - foreach ($path->path as $branchId) { - if (!isset($branches[$branchId])) { - // @codeCoverageIgnoreStart - continue; - // @codeCoverageIgnoreEnd + $paths .= '
    ' . $pathCount . ' paths — click to expand' . "\n"; + $paths .= '

    Only the first ' . self::MAX_RENDERED_PATHS . ' paths are shown, consider refactoring your code to bring the number of paths down.

    ' . "\n"; } - if ($first) { - $first = false; - } else { - $lines .= '  ' . "\n"; - } + $paths .= '' . "\n"; + $paths .= '' . "\n"; + $paths .= '' . "\n"; + + $pathIndex = 1; + + foreach ($renderedPaths 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) { + if (!isset($methodData->branches[$branchId])) { + // @codeCoverageIgnoreStart + continue; + // @codeCoverageIgnoreEnd + } + + $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 (!isset($testData[$test])) { @@ -879,49 +944,118 @@ private function renderPathLines(ProcessedPathCoverageData $path, array $branche // @codeCoverageIgnoreEnd } - 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++; + } + + $paths .= '
    #BranchesStatusTests
    %d%s%s%s
    ' . "\n"; + + $pathsJson = []; + $pathIdx = 0; + + foreach ($renderedPaths as $path) { + $edges = []; + $previousBranchId = null; + $lastBranchId = null; + + foreach ($path->path as $branchId) { + if ($previousBranchId !== null) { + $edges[] = $previousBranchId . '-' . $branchId; + } + + $previousBranchId = $branchId; + $lastBranchId = $branchId; + } + + if ($lastBranchId !== null && 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, $renderedPaths); + + $paths .= sprintf( + '
    %s
    ' . "\n", + htmlspecialchars(json_encode($pathsJson, JSON_THROW_ON_ERROR), self::HTML_SPECIAL_CHARS_FLAGS), + $svg, + ); + + if ($pathCount > self::MAX_RENDERED_PATHS) { + $paths .= '
    ' . "\n"; } } - if ($lines === '') { - return ''; + $pathsTemplate->setVar(['paths' => $paths]); + + return $pathsTemplate->render(); + } + + private function controlFlowGraph(): ControlFlowGraph + { + if ($this->controlFlowGraph === null) { + $this->controlFlowGraph = new ControlFlowGraph; } - $linesTemplate->setVar(['lines' => $lines]); + return $this->controlFlowGraph; + } - return $linesTemplate->render(); + /** + * @param array $coverageData + * + * @return array + */ + private function sortedByStartLine(array $coverageData): array + { + uasort( + $coverageData, + static function (ProcessedFunctionCoverageData $a, ProcessedFunctionCoverageData $b): int + { + return self::startLine($a) <=> self::startLine($b); + }, + ); + + return $coverageData; } - 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, ], ); @@ -984,4 +1118,15 @@ private function createPopoverContentForTest(string $test, array $testData): str htmlspecialchars($test, self::HTML_SPECIAL_CHARS_FLAGS), ); } + + private static function startLine(ProcessedFunctionCoverageData $methodData): int + { + $startLine = PHP_INT_MAX; + + foreach ($methodData->branches as $branch) { + $startLine = min($startLine, $branch->line_start, $branch->line_end); + } + + return $startLine; + } } diff --git a/src/Report/Html/Renderer/Template/css/style.css b/src/Report/Html/Renderer/Template/css/style.css index 3d643dee2..c8c2d09de 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); } @@ -228,6 +252,19 @@ table#code tr { flex-wrap: nowrap; } +.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 { flex: 0 0 auto; width: auto; @@ -296,3 +333,87 @@ table#code td:first-of-type a { .dashboard-column .scrollbox { height: 400px; } + +.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 text { + fill: var(--bs-body-color); +} + +.cfg-graph .node polygon, +.cfg-graph .node ellipse { + fill: var(--bs-tertiary-bg); + stroke: var(--bs-secondary-color); +} + +.cfg-graph .node.covered polygon { + fill: var(--phpunit-success-low); + stroke: var(--phpunit-success-bar); +} + +.cfg-graph .node.uncovered polygon { + fill: var(--phpunit-danger); + stroke: var(--phpunit-danger-bar); +} + +.cfg-graph .edge path { + stroke: var(--bs-secondary-color); +} + +.cfg-graph .edge polygon { + fill: var(--bs-secondary-color); + stroke: var(--bs-secondary-color); +} + +.cfg-graph .edge.covered path { + stroke: var(--phpunit-success-bar); +} + +.cfg-graph .edge.covered polygon { + fill: var(--phpunit-success-bar); + stroke: var(--phpunit-success-bar); +} + +.cfg-graph .edge.uncovered path { + stroke: var(--phpunit-danger-bar); +} + +.cfg-graph .edge.uncovered polygon { + fill: var(--phpunit-danger-bar); + stroke: var(--phpunit-danger-bar); +} + +.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 46be3117f..42076eb31 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/file_branch_and_path.html.dist b/src/Report/Html/Renderer/Template/file_branch_and_path.html.dist index b8bcf3747..53cacc6c3 100644 --- a/src/Report/Html/Renderer/Template/file_branch_and_path.html.dist +++ b/src/Report/Html/Renderer/Template/file_branch_and_path.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 2a9a95a0f..eb2b7b30a 100644 --- a/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html +++ b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php.html @@ -26,6 +26,11 @@
    +
    @@ -291,40 +296,40 @@
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    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/PathCoverageForBankAccount/BankAccount.php_branch.html b/tests/_files/Report/HTML/PathCoverageForBankAccount/BankAccount.php_branch.html index d81644fed..c8134584a 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 d81644fed..f337e541b 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..443588f57 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:%ssource_without_namespace.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..cc35a7b9f 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:%ssource_without_namespace.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