Modules/businessdev.ALbuild.Apps/Private/Write-BcCoverageReports.ps1
|
function Write-CoberturaXml { <# .SYNOPSIS Writes Cobertura XML from ALbuild coverage objects (consumed by Azure DevOps / ReportGenerator). Branch coverage is omitted (BC does not provide branch data). #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes a report file at an explicit caller-provided path; no destructive ambiguity.')] [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Objects, [Parameter(Mandatory)] [object] $Summary, [string] $SourceRoot, [Parameter(Mandatory)] [string] $Path ) $ic = [System.Globalization.CultureInfo]::InvariantCulture $rate = ([math]::Round($Summary.lineCoverage / 100, 4)).ToString($ic) $doc = New-Object System.Xml.XmlDocument $doc.AppendChild($doc.CreateXmlDeclaration('1.0', 'UTF-8', $null)) | Out-Null $cov = $doc.CreateElement('coverage') $cov.SetAttribute('line-rate', $rate) $cov.SetAttribute('branch-rate', '0') $cov.SetAttribute('lines-covered', [string]$Summary.coveredLines) $cov.SetAttribute('lines-valid', [string]$Summary.totalExecutableLines) $cov.SetAttribute('branches-covered', '0') $cov.SetAttribute('branches-valid', '0') $cov.SetAttribute('complexity', '0') $cov.SetAttribute('version', 'ALbuild') $cov.SetAttribute('timestamp', [string][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) $doc.AppendChild($cov) | Out-Null $sources = $doc.CreateElement('sources') if ($SourceRoot) { $s = $doc.CreateElement('source'); $s.InnerText = $SourceRoot; $sources.AppendChild($s) | Out-Null } $cov.AppendChild($sources) | Out-Null $packages = $doc.CreateElement('packages'); $cov.AppendChild($packages) | Out-Null $package = $doc.CreateElement('package') $package.SetAttribute('name', 'ALbuild') $package.SetAttribute('line-rate', $rate) $package.SetAttribute('branch-rate', '0') $package.SetAttribute('complexity', '0') $packages.AppendChild($package) | Out-Null $classes = $doc.CreateElement('classes'); $package.AppendChild($classes) | Out-Null foreach ($o in $Objects) { $class = $doc.CreateElement('class') $class.SetAttribute('name', ("{0} {1} {2}" -f $o.objectType, $o.objectId, $o.objectName).Trim()) $class.SetAttribute('filename', $(if ($o.filePath) { $o.filePath } else { "$($o.objectType)$($o.objectId)" })) $class.SetAttribute('line-rate', ([math]::Round($o.lineCoverage / 100, 4)).ToString($ic)) $class.SetAttribute('branch-rate', '0') $class.SetAttribute('complexity', '0') $class.AppendChild($doc.CreateElement('methods')) | Out-Null $lines = $doc.CreateElement('lines') foreach ($l in $o.lines) { $line = $doc.CreateElement('line') $line.SetAttribute('number', [string]$l.lineNo) $line.SetAttribute('hits', [string]$l.hits) $line.SetAttribute('branch', 'false') $lines.AppendChild($line) | Out-Null } $class.AppendChild($lines) | Out-Null $classes.AppendChild($class) | Out-Null } $doc.Save($Path) } function Write-CoverageMarkdown { <# .SYNOPSIS Writes a Markdown coverage summary (Azure DevOps / GitHub step summary). #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes a report file at an explicit caller-provided path; no destructive ambiguity.')] [CmdletBinding()] param( [Parameter(Mandatory)] [object[]] $Objects, [Parameter(Mandatory)] [object] $Summary, [Parameter(Mandatory)] [string] $Path ) $ic = [System.Globalization.CultureInfo]::InvariantCulture $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('# ALbuild Code Coverage Summary').AppendLine() [void]$sb.AppendLine([string]::Format($ic, '**Overall line coverage: {0}% ({1} / {2} lines)** across {3} object(s).', $Summary.lineCoverage, $Summary.coveredLines, $Summary.totalExecutableLines, $Summary.objectCount)).AppendLine() $worst = @($Objects | Sort-Object lineCoverage | Select-Object -First 10) if ($worst.Count -gt 0) { [void]$sb.AppendLine('## Lowest-covered objects').AppendLine() [void]$sb.AppendLine('| Object | File | Coverage |').AppendLine('|---|---|---:|') foreach ($o in $worst) { $obj = ("{0} {1} {2}" -f $o.objectType, $o.objectId, $o.objectName).Trim() $file = if ($o.filePath) { $o.filePath } else { '-' } [void]$sb.AppendLine([string]::Format($ic, '| {0} | {1} | {2}% |', $obj, $file, $o.lineCoverage)) } } Set-Content -LiteralPath $Path -Value $sb.ToString() -Encoding UTF8 } function ConvertTo-BcLineRangeText { <# .SYNOPSIS Condenses line numbers into ranges: 112,113,114,115,116,117,118,140 -> "112-118, 140". .DESCRIPTION A few hundred individual uncovered line numbers are unreadable. As ranges they show at a glance whether a whole procedure is untested or only single branches are missing. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)] [AllowEmptyCollection()] [int[]] $Line) $sorted = @($Line | Sort-Object -Unique) if ($sorted.Count -eq 0) { return '' } $parts = [System.Collections.Generic.List[string]]::new() $start = $sorted[0]; $prev = $sorted[0] foreach ($n in ($sorted | Select-Object -Skip 1)) { if ($n -eq $prev + 1) { $prev = $n; continue } $parts.Add($(if ($start -eq $prev) { "$start" } else { "$start-$prev" })) $start = $n; $prev = $n } $parts.Add($(if ($start -eq $prev) { "$start" } else { "$start-$prev" })) return ($parts -join ', ') } function Write-CoverageHtml { <# .SYNOPSIS Writes a self-contained HTML coverage report for the Azure DevOps Code Coverage tab. .DESCRIPTION ALbuild publishes coverage through the '##vso[codecoverage.publish]' logging command. Unlike the PublishCodeCoverageResults task, that command does NOT generate a report - it uploads whatever is in the report directory. The directory was created empty, so the tab showed "Code coverage report cannot be rendered as report HTML was not found" even though the percentages, the gate and the Markdown summary were all correct. This writer fills that directory. The report answers "where are tests missing?", not just "what is the number": objects are listed worst-covered first, each with its uncovered lines condensed to ranges and its annotated source. Everything is inline - CSS, JavaScript, no images, no fonts, no @import. Azure DevOps serves the report from a sandboxed frame where any external request fails silently, and the artifact must also be readable offline. .PARAMETER Objects The per-object coverage records (objectType, objectId, objectName, filePath, lineCoverage, coveredLines, totalExecutableLines, lines[]). .PARAMETER Summary The overall summary (lineCoverage, coveredLines, totalExecutableLines, objectCount, denominatorMode, sourcedObjectCount). .PARAMETER SourceRoot Workspace root used to resolve each object's relative filePath for the annotated source. Without it the report is still complete, just without source. .PARAMETER Path The index.html to write. .PARAMETER MaxObjects Object cap. Microsoft documents that the coverage tab stops rendering at roughly 7 MB, which a large app with per-test coverage can exceed. .PARAMETER MaxBytes Size budget for the whole document. Annotated source is the main driver of report size, so it is the first thing dropped when the budget runs out; only then is the object list truncated. Any reduction is stated in the report AND logged as a warning - silently showing a partial report would be worse than showing none. .OUTPUTS PSCustomObject: Path, ObjectsRendered, ObjectsTotal, SourceOmitted, Truncated. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes a report file at an explicit caller-provided path; no destructive ambiguity.')] [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Objects, [Parameter(Mandatory)] [object] $Summary, [string] $SourceRoot, [Parameter(Mandatory)] [string] $Path, [int] $MaxObjects = 2000, [int] $MaxBytes = 4194304 ) $ic = [System.Globalization.CultureInfo]::InvariantCulture $esc = { param($value) if ($null -eq $value) { return '' } "$value".Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace('"', '"') } $pct = { param($value) [string]::Format($ic, '{0:0.##}', [double] $value) } $all = @($Objects) # Worst first: that is the working order for closing gaps, and it decides what survives truncation. $ordered = @($all | Sort-Object @{ Expression = { [double] $_.lineCoverage } }, @{ Expression = { "$($_.objectType) $($_.objectId)" } }) $rendered = @($ordered | Select-Object -First $MaxObjects) $truncated = $all.Count -gt $rendered.Count $style = @' :root{--bg:#faf9f5;--fg:#141413;--mut:#6b6b68;--rule:#e0dfd8;--card:#fff;--ok:#2c6e46;--bad:#a33232;--accent:#0d6154} @media(prefers-color-scheme:dark){:root{--bg:#121614;--fg:#e7ecea;--mut:#93a29c;--rule:#2b3330;--card:#19201d;--ok:#63b783;--bad:#d97a7a;--accent:#4cbaa4}} *{box-sizing:border-box} body{margin:0;padding:24px;background:var(--bg);color:var(--fg);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif} h1{font-size:20px;margin:0 0 4px} .sum{display:flex;flex-wrap:wrap;gap:22px;align-items:baseline;padding:16px 18px;background:var(--card);border:1px solid var(--rule);border-radius:5px;margin-bottom:18px} .big{font-size:30px;font-weight:650;font-variant-numeric:tabular-nums} .meta{color:var(--mut);font-size:12.5px} .meta b{color:var(--fg);font-weight:600} .note{border-left:3px solid var(--bad);background:var(--card);padding:10px 14px;margin-bottom:16px;font-size:13px} input.f{padding:7px 10px;border:1px solid var(--rule);border-radius:4px;background:var(--card);color:var(--fg);width:280px;margin-bottom:10px;font:inherit} table{border-collapse:collapse;width:100%;background:var(--card);border:1px solid var(--rule);border-radius:5px;overflow:hidden} th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--rule);font-size:13px;vertical-align:top} th{background:var(--bg);font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--mut);cursor:pointer;white-space:nowrap;user-select:none} th:after{content:" \2195";opacity:.35} td.n{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap} td.f{font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--mut)} .bar{display:inline-block;width:110px;height:8px;border-radius:2px;background:var(--bad);overflow:hidden;vertical-align:middle} .bar>i{display:block;height:100%;background:var(--ok)} .nofile{color:var(--bad);font-style:italic} button.tg{border:1px solid var(--rule);background:var(--bg);color:var(--fg);border-radius:3px;cursor:pointer;font:inherit;font-size:11px;padding:0 6px;margin-right:6px} tr.d>td{background:var(--bg)} .rng{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-word} pre.src{margin:8px 0 0;padding:0;overflow-x:auto;font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.45;border-top:1px solid var(--rule)} pre.src span.l{display:block;white-space:pre} pre.src i{display:inline-block;width:52px;text-align:right;padding-right:10px;color:var(--mut);font-style:normal;user-select:none} pre.src .cov{background:rgba(44,110,70,.13)} pre.src .unc{background:rgba(163,50,50,.16)} '@ $script = @' (function(){ var f=document.getElementById("flt"),t=document.getElementById("objs"); if(f)f.addEventListener("input",function(){ var q=f.value.toLowerCase(); Array.prototype.forEach.call(t.tBodies[0].rows,function(r){ if(r.className==="d"){r.hidden=true;return;} r.hidden=q&&r.getAttribute("data-k").indexOf(q)<0; }); }); Array.prototype.forEach.call(t.tHead.rows[0].cells,function(h,i){ h.addEventListener("click",function(){ var asc=h.getAttribute("data-asc")!=="1"; Array.prototype.forEach.call(t.tHead.rows[0].cells,function(x){x.removeAttribute("data-asc")}); h.setAttribute("data-asc",asc?"1":"0"); var body=t.tBodies[0],pairs=[]; for(var r=0;r<body.rows.length;r+=2)pairs.push([body.rows[r],body.rows[r+1]]); pairs.sort(function(a,b){ var x=a[0].cells[i].getAttribute("data-s"),y=b[0].cells[i].getAttribute("data-s"); if(x===null)x=a[0].cells[i].textContent; if(y===null)y=b[0].cells[i].textContent; var nx=parseFloat(x),ny=parseFloat(y); var c=(!isNaN(nx)&&!isNaN(ny))?nx-ny:String(x).localeCompare(String(y)); return asc?c:-c; }); pairs.forEach(function(p){body.appendChild(p[0]);body.appendChild(p[1]);}); }); }); t.addEventListener("click",function(e){ var b=e.target.closest?e.target.closest("button.tg"):null; if(!b)return; var d=b.closest("tr").nextElementSibling; d.hidden=!d.hidden;b.textContent=d.hidden?"+":"-"; }); })(); '@ $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('<!doctype html><html lang="en"><head><meta charset="utf-8">') [void]$sb.AppendLine('<meta name="viewport" content="width=device-width,initial-scale=1">') [void]$sb.AppendLine('<title>ALbuild Code Coverage</title>') [void]$sb.AppendLine('<style>').Append($style).AppendLine('</style></head><body>') [void]$sb.AppendLine('<h1>ALbuild Code Coverage</h1>') # Header. The denominator mode belongs here: 'CoveredOnly' means the denominator came from the # coverage export itself and the percentage is not comparable to a source-derived one. [void]$sb.AppendLine('<div class="sum">') [void]$sb.AppendLine([string]::Format($ic, '<span class="big">{0}%</span>', (& $pct $Summary.lineCoverage))) [void]$sb.AppendLine([string]::Format($ic, '<span class="meta"><b>{0}</b> / {1} lines covered</span>', $Summary.coveredLines, $Summary.totalExecutableLines)) [void]$sb.AppendLine([string]::Format($ic, '<span class="meta"><b>{0}</b> object(s)</span>', $Summary.objectCount)) $mode = & $esc $Summary.denominatorMode $sourced = if ($Summary.PSObject.Properties.Name -contains 'sourcedObjectCount') { $Summary.sourcedObjectCount } else { '' } [void]$sb.AppendLine([string]::Format($ic, '<span class="meta">denominator <b>{0}</b>{1}</span>', $mode, $(if ("$sourced" -ne '') { " ($sourced from source)" } else { '' }))) [void]$sb.AppendLine('</div>') if ($truncated) { [void]$sb.AppendLine([string]::Format($ic, '<div class="note">Showing the {0} lowest-covered of {1} objects. The Azure DevOps coverage tab stops rendering large reports, so the list was capped.</div>', $rendered.Count, $all.Count)) } [void]$sb.AppendLine('<input id="flt" class="f" type="text" placeholder="Filter objects...">') [void]$sb.AppendLine('<table id="objs"><thead><tr><th>Object</th><th>Name</th><th>File</th><th>Lines</th><th>Coverage</th></tr></thead><tbody>') # Rows first, source afterwards: if the budget runs out, every object still appears in the table and # only the source is missing. $details = @{} foreach ($o in $rendered) { $objLabel = ("{0} {1}" -f $o.objectType, $o.objectId).Trim() $name = & $esc $o.objectName $file = if ($o.filePath) { & $esc $o.filePath } else { '<span class="nofile">no source file</span>' } $cov = [double] $o.lineCoverage $key = ("$objLabel $($o.objectName) $($o.filePath)").ToLowerInvariant() [void]$sb.AppendLine([string]::Format($ic, '<tr class="o" data-k="{0}"><td><button class="tg" type="button">+</button>{1}</td><td>{2}</td><td class="f">{3}</td><td class="n" data-s="{4}">{4}/{5}</td><td class="n" data-s="{6}"><span class="bar"><i style="width:{6}%"></i></span> {6}%</td></tr>', (& $esc $key), (& $esc $objLabel), $name, $file, $o.coveredLines, $o.totalExecutableLines, (& $pct $cov))) $uncovered = @($o.lines | Where-Object { [int] $_.hits -eq 0 } | ForEach-Object { [int] $_.lineNo }) $ranges = ConvertTo-BcLineRangeText -Line $uncovered $body = if ($ranges) { '<b>Uncovered lines:</b> <span class="rng">' + (& $esc $ranges) + '</span>' } else { 'Fully covered.' } $details[$objLabel] = @{ Row = $sb.Length; Object = $o; Body = $body } [void]$sb.AppendLine('<tr class="d" hidden><td colspan="5">' + $body + '__SRC_' + ($details.Count - 1) + '__</td></tr>') } [void]$sb.AppendLine('</tbody></table>') [void]$sb.AppendLine('<script>').Append($script).AppendLine('</script></body></html>') # Annotated source, appended into the placeholders while the size budget allows it. $html = $sb.ToString() $sourceOmitted = 0 $index = -1 foreach ($o in $rendered) { $index++ $token = '__SRC_' + $index + '__' $snippet = '' $srcFile = $null if ($o.filePath -and $SourceRoot) { $srcFile = Join-Path $SourceRoot ($o.filePath -replace '/', [System.IO.Path]::DirectorySeparatorChar) } if ($srcFile -and (Test-Path -LiteralPath $srcFile) -and $html.Length -lt $MaxBytes) { $status = @{} foreach ($l in @($o.lines)) { $status[[int] $l.lineNo] = ([int] $l.hits -gt 0) } $srcSb = [System.Text.StringBuilder]::new() [void]$srcSb.Append('<pre class="src">') $no = 0 foreach ($text in (Get-Content -LiteralPath $srcFile -ErrorAction SilentlyContinue)) { $no++ $cls = if ($status.ContainsKey($no)) { if ($status[$no]) { ' cov' } else { ' unc' } } else { '' } [void]$srcSb.Append('<span class="l' + $cls + '"><i>' + $no + '</i>' + (& $esc $text) + '</span>') } [void]$srcSb.Append('</pre>') if ($html.Length + $srcSb.Length -lt $MaxBytes) { $snippet = $srcSb.ToString() } else { $sourceOmitted++ } } elseif ($srcFile) { $sourceOmitted++ } $html = $html.Replace($token, $snippet) } if ($sourceOmitted -gt 0) { $html = $html.Replace('<input id="flt"', ('<div class="note">Annotated source omitted for ' + $sourceOmitted + ' object(s) to keep the report within the size the coverage tab can render.</div><input id="flt"')) } if ($truncated -or $sourceOmitted -gt 0) { Write-ALbuildLog -Level Warning ("Coverage HTML report reduced to stay renderable: " + "$($rendered.Count) of $($all.Count) object(s) listed, annotated source omitted for $sourceOmitted. " + 'The percentages and the gate are unaffected.') } $parent = Split-Path -Parent $Path if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } # Explicit UTF-8 BOM: PowerShell 7's -Encoding UTF8 writes no BOM while 5.1's does, and the report # must render umlauts in object and file names identically from either edition. [System.IO.File]::WriteAllText($Path, $html, (New-Object System.Text.UTF8Encoding($true))) return [PSCustomObject]@{ Path = $Path ObjectsRendered = $rendered.Count ObjectsTotal = $all.Count SourceOmitted = $sourceOmitted Truncated = $truncated } } |