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. THE TAB RUNS NO JAVASCRIPT. Azure DevOps embeds the report in an iframe whose sandbox does not include 'allow-scripts', so every script in this document is dead there and only runs when the artifact is opened in a browser. The first version of this report ignored that and offered a '[+]' button plus a filter box that did nothing, and it kept the annotated source in a table cell, where one expanded object's unwrapped AL lines stretched the table far beyond the viewport and pushed the other columns out of sight. Hence the rules this writer follows: - collapsing uses <details>/<summary>, table-to-detail navigation uses '#' fragments; neither needs script - the script only ADDS things (filter box, sortable headers) and creates its own controls, so nothing that cannot work is ever visible - no content that can be arbitrarily wide goes inside the table; source sits in its own horizontally scrollable box - no CSS custom properties, no flexbox: the document stays legible even if only part of the stylesheet is honoured .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 # No CSS custom properties and no flexbox: the report has to stay legible even where only part of the # stylesheet takes effect, so every rule is a literal value and layout comes from block flow and one # plain table. Nothing here is required to understand the numbers - that is the point. $style = @' html{-webkit-text-size-adjust:100%} body{margin:0;padding:20px 22px 40px;background:#faf9f5;color:#141413;font:14px/1.55 "Segoe UI",-apple-system,BlinkMacSystemFont,Roboto,Arial,sans-serif} .wrap{max-width:1180px} h1{font-size:19px;font-weight:650;margin:0 0 14px} h2{font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.07em;color:#6b6b68;margin:26px 0 9px;padding-bottom:6px;border-bottom:1px solid #e0dfd8} .sum{padding:14px 16px;margin:0 0 14px;background:#fff;border:1px solid #e0dfd8;border-radius:6px} .pct{font-size:32px;font-weight:650;line-height:1.15} .pct .u{font-size:17px;font-weight:600} .facts{margin-top:2px;font-size:13px;color:#5c5c58} .facts b{color:#141413;font-weight:650} .mut{color:#6b6b68} .note{margin:0 0 12px;padding:9px 12px;background:#fff;border:1px solid #e0dfd8;border-left:3px solid #a33232;border-radius:4px;font-size:13px} table.ov{border-collapse:collapse;width:100%;table-layout:fixed;background:#fff;border:1px solid #e0dfd8} table.ov th,table.ov td{padding:7px 10px;border-bottom:1px solid #eeece5;font-size:13px;text-align:left;vertical-align:top;overflow-wrap:anywhere} table.ov th{font-size:11px;font-weight:650;text-transform:uppercase;letter-spacing:.06em;color:#6b6b68;background:#f4f2ec;white-space:nowrap} table.ov td.n{text-align:right;white-space:nowrap} table.ov td.fp{font-family:Consolas,"Courier New",monospace;font-size:12px;color:#5c5c58} .w1{width:16%}.w2{width:28%}.w3{width:30%}.w4{width:11%}.w5{width:15%} a.jump{color:#0d6154;text-decoration:none} a.jump:hover{text-decoration:underline} .bar{display:inline-block;width:56px;height:7px;border-radius:2px;background:#e6d8d8;overflow:hidden;vertical-align:middle;margin-right:6px} .bar>i{display:block;height:100%;background:#2c6e46} .nofile{color:#a33232;font-style:italic} details.ob{background:#fff;border:1px solid #e0dfd8;margin:0 0 6px} details.ob>summary{padding:8px 12px;cursor:pointer;font-size:13px} .sp{display:inline-block;min-width:60px;font-weight:650} .lo{color:#a33232}.mid{color:#8a6d1f}.hi{color:#2c6e46} .inner{padding:0 12px 12px} .dmeta{margin:0 0 8px;font-size:12px;color:#6b6b68;font-family:Consolas,"Courier New",monospace;overflow-wrap:anywhere} .rng{margin:0 0 10px;font-size:12px;font-family:Consolas,"Courier New",monospace;overflow-wrap:anywhere} .srcwrap{overflow-x:auto;max-width:100%;border:1px solid #eeece5;background:#fdfdfb} pre.src{margin:0;padding:8px 0;font-family:Consolas,"Courier New",monospace;font-size:12px;line-height:1.5} pre.src span.l{display:block;white-space:pre;padding-right:14px} pre.src i{display:inline-block;width:46px;padding-right:10px;text-align:right;color:#9a9a95;font-style:normal} pre.src .cov{background:rgba(44,110,70,.10)} pre.src .unc{background:rgba(163,50,50,.13)} .legend{margin:0 0 10px;font-size:12px;color:#6b6b68} .sw{display:inline-block;width:11px;height:11px;margin:0 5px 0 16px;vertical-align:-1px;border-radius:2px} .legend .sw:first-child{margin-left:0} .sw-cov{background:#b3d2bf} .sw-unc{background:#e3b9b9} .sw-non{background:transparent;border:1px solid #d8d6cd} input.flt{padding:6px 9px;margin:0 0 9px;border:1px solid #d8d6cd;border-radius:4px;background:#fff;color:#141413;width:270px;font:inherit;font-size:13px} @media(prefers-color-scheme:dark){ body{background:#121614;color:#e7ecea} h2{color:#93a29c;border-bottom-color:#2b3330} .sum,.note,table.ov,details.ob{background:#19201d;border-color:#2b3330} .note{border-left-color:#d97a7a} .facts{color:#a9b6b1}.facts b{color:#e7ecea}.mut{color:#93a29c} table.ov th{background:#151b19;color:#93a29c} table.ov th,table.ov td{border-bottom-color:#2b3330} table.ov td.fp{color:#a9b6b1} a.jump{color:#4cbaa4} .bar{background:#3a2a2a}.bar>i{background:#63b783} .nofile,.lo{color:#d97a7a}.mid{color:#d3b45e}.hi{color:#63b783} .dmeta,.rng,.legend{color:#93a29c} .srcwrap{background:#141a18;border-color:#2b3330} pre.src i{color:#6f7d78} pre.src .cov{background:rgba(99,183,131,.12)} pre.src .unc{background:rgba(217,122,122,.16)} .sw-cov{background:#2f5c42}.sw-unc{background:#6b3636}.sw-non{border-color:#3a4440} input.flt{background:#19201d;color:#e7ecea;border-color:#2b3330} } '@ # Pure enhancement, and it has to stay that way. Azure DevOps renders the report inside a sandboxed # frame WITHOUT 'allow-scripts', so none of this runs in the Code Coverage tab - it only runs when the # artifact is opened in a browser. That is why the script CREATES the filter box instead of the markup # carrying it: a control that cannot work must not be visible. Collapsing is done by <details>, which # needs no script, and the table already arrives sorted worst-first. $script = @' (function(){ var t=document.getElementById("ov"); if(!t||!t.tHead||!t.tBodies.length)return; var box=document.createElement("input"); box.type="text";box.className="flt";box.placeholder="Filter objects..."; box.setAttribute("aria-label","Filter objects"); t.parentNode.insertBefore(box,t); box.addEventListener("input",function(){ var q=box.value.toLowerCase(),rows=t.tBodies[0].rows,i; for(i=0;i<rows.length;i++){ var k=rows[i].getAttribute("data-k")||""; rows[i].style.display=(q&&k.indexOf(q)<0)?"none":""; } }); var hs=t.tHead.rows[0].cells,j; for(j=0;j<hs.length;j++)(function(h,i){ h.style.cursor="pointer"; h.title="Sort by this column"; h.addEventListener("click",function(){ var asc=h.getAttribute("data-asc")!=="1",k,m; for(k=0;k<hs.length;k++)hs[k].removeAttribute("data-asc"); h.setAttribute("data-asc",asc?"1":"0"); var b=t.tBodies[0],rows=[]; for(m=0;m<b.rows.length;m++)rows.push(b.rows[m]); rows.sort(function(x,y){ var a=x.cells[i].getAttribute("data-s"),c=y.cells[i].getAttribute("data-s"); if(a===null)a=x.cells[i].textContent; if(c===null)c=y.cells[i].textContent; var na=parseFloat(a),nc=parseFloat(c); var d=(!isNaN(na)&&!isNaN(nc))?na-nc:String(a).localeCompare(String(c)); return asc?d:-d; }); for(m=0;m<rows.length;m++)b.appendChild(rows[m]); }); })(hs[j],j); })(); '@ $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><div class="wrap">') [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. Each fact is # its own block and carries its own words, so the header still reads correctly with no styling at all. [void]$sb.AppendLine('<div class="sum">') [void]$sb.AppendLine([string]::Format($ic, '<div class="pct">{0}<span class="u"> %</span></div>', (& $pct $Summary.lineCoverage))) [void]$sb.AppendLine([string]::Format($ic, '<div class="facts"><b>{0}</b> of <b>{1}</b> executable lines covered</div>', $Summary.coveredLines, $Summary.totalExecutableLines)) $mode = & $esc $Summary.denominatorMode $sourcedText = '' if (($Summary.PSObject.Properties.Name -contains 'sourcedObjectCount') -and ("$($Summary.sourcedObjectCount)" -ne '')) { $sourcedText = [string]::Format($ic, ' <span class="mut">({0} measured from source)</span>', $Summary.sourcedObjectCount) } [void]$sb.AppendLine([string]::Format($ic, '<div class="facts"><b>{0}</b> object(s) · denominator <b>{1}</b>{2}</div>', $Summary.objectCount, $mode, $sourcedText)) [void]$sb.AppendLine('</div>') # Reductions are only known once everything has been rendered, but they belong above the data. [void]$sb.AppendLine('__NOTES__') [void]$sb.AppendLine('<h2>Objects, lowest coverage first</h2>') [void]$sb.AppendLine('<table class="ov" id="ov"><thead><tr><th class="w1">Object</th><th class="w2">Name</th><th class="w3">File</th><th class="w4">Lines</th><th class="w5">Coverage</th></tr></thead><tbody>') $i = -1 foreach ($o in $rendered) { $i++ $objLabel = ("{0} {1}" -f $o.objectType, $o.objectId).Trim() $fileCell = '<span class="nofile">no source file</span>' if ($o.filePath) { $fileCell = & $esc $o.filePath } $key = ("$objLabel $($o.objectName) $($o.filePath)").ToLowerInvariant() # The object name links to its detail section - fragment navigation is the one form of # interaction that survives a frame without 'allow-scripts'. [void]$sb.AppendLine([string]::Format($ic, '<tr data-k="{0}"><td><a class="jump" href="#ob{1}">{2}</a></td><td>{3}</td><td class="fp">{4}</td><td class="n" data-s="{5}">{5} / {6}</td><td class="n" data-s="{7}"><span class="bar"><i style="width:{7}%"></i></span>{7} %</td></tr>', (& $esc $key), $i, (& $esc $objLabel), (& $esc $o.objectName), $fileCell, $o.coveredLines, $o.totalExecutableLines, (& $pct ([double] $o.lineCoverage)))) } [void]$sb.AppendLine('</tbody></table>') # Detail sections live OUTSIDE the table on purpose. While they were table rows, one expanded object's # unwrapped source stretched the table far past the viewport and pushed every other column out of # sight. Here a long line can only scroll inside its own box. [void]$sb.AppendLine('<h2>Uncovered lines and annotated source</h2>') # The '·' separators are not decoration: without a stylesheet the colour swatches collapse to # nothing and the three labels would run together into one word. [void]$sb.AppendLine('<p class="legend"><span class="sw sw-cov"></span>covered · <span class="sw sw-unc"></span>not covered · <span class="sw sw-non"></span>not executable</p>') $sourceOmitted = 0 $sourceMissing = 0 $i = -1 foreach ($o in $rendered) { $i++ $objLabel = ("{0} {1}" -f $o.objectType, $o.objectId).Trim() $cov = [double] $o.lineCoverage $covClass = 'lo' if ($cov -ge 80) { $covClass = 'hi' } elseif ($cov -ge 50) { $covClass = 'mid' } # <details>/<summary> collapses without a single line of script. [void]$sb.AppendLine([string]::Format($ic, '<details class="ob" id="ob{0}"><summary><span class="sp {1}">{2} %</span> <b>{3}</b> <span class="mut">{4}</span></summary><div class="inner">', $i, $covClass, (& $pct $cov), (& $esc $objLabel), (& $esc $o.objectName))) $fileText = 'no source file' if ($o.filePath) { $fileText = & $esc $o.filePath } [void]$sb.AppendLine([string]::Format($ic, '<p class="dmeta">{0} · {1} of {2} executable lines covered</p>', $fileText, $o.coveredLines, $o.totalExecutableLines)) $uncovered = @($o.lines | Where-Object { [int] $_.hits -eq 0 } | ForEach-Object { [int] $_.lineNo }) $ranges = ConvertTo-BcLineRangeText -Line $uncovered if ($ranges) { [void]$sb.AppendLine('<p class="rng"><b>Uncovered lines:</b> ' + (& $esc $ranges) + '</p>') } else { [void]$sb.AppendLine('<p class="rng">Fully covered.</p>') } $srcFile = $null if ($o.filePath -and $SourceRoot) { $srcFile = Join-Path $SourceRoot ($o.filePath -replace '/', [System.IO.Path]::DirectorySeparatorChar) } if ($srcFile) { if (-not (Test-Path -LiteralPath $srcFile)) { # A missing file is a different problem from a full budget - usually a wrong workspace # root - and saying "omitted for size" about it would send the reader the wrong way. $sourceMissing++ [void]$sb.AppendLine('<p class="dmeta">Annotated source not shown: the file was not found below the workspace root.</p>') } elseif ($sb.Length -ge $MaxBytes) { $sourceOmitted++ } else { $status = @{} foreach ($l in @($o.lines)) { $status[[int] $l.lineNo] = ([int] $l.hits -gt 0) } $srcSb = [System.Text.StringBuilder]::new() [void]$srcSb.Append('<div class="srcwrap"><pre class="src">') $no = 0 foreach ($text in (Get-Content -LiteralPath $srcFile -ErrorAction SilentlyContinue)) { $no++ $cls = '' if ($status.ContainsKey($no)) { if ($status[$no]) { $cls = ' cov' } else { $cls = ' unc' } } [void]$srcSb.Append('<span class="l' + $cls + '"><i>' + $no + '</i>' + (& $esc $text) + '</span>') } [void]$srcSb.Append('</pre></div>') if (($sb.Length + $srcSb.Length) -lt $MaxBytes) { [void]$sb.Append($srcSb.ToString()) } else { $sourceOmitted++ } } } [void]$sb.AppendLine('</div></details>') } [void]$sb.AppendLine('</div>') [void]$sb.AppendLine('<script>').Append($script).AppendLine('</script></body></html>') $notes = '' if ($truncated) { $notes += [string]::Format($ic, '<p class="note">Showing the {0} lowest-covered of {1} objects. The Azure DevOps coverage tab stops rendering large reports, so the list was capped.</p>', $rendered.Count, $all.Count) } if ($sourceOmitted -gt 0) { $notes += '<p class="note">Annotated source omitted for ' + $sourceOmitted + ' object(s) to keep the report within the size the coverage tab can render.</p>' } if ($sourceMissing -gt 0) { $notes += '<p class="note">' + $sourceMissing + ' object(s) have a source file that was not found below the workspace root, so no annotated source is shown for them.</p>' } $html = $sb.ToString().Replace('__NOTES__', $notes) if ($truncated -or $sourceOmitted -gt 0 -or $sourceMissing -gt 0) { $why = @() if ($truncated) { $why += "$($rendered.Count) of $($all.Count) object(s) listed" } if ($sourceOmitted -gt 0) { $why += "annotated source dropped for $sourceOmitted object(s) to stay inside the $([int] ($MaxBytes / 1024)) KB budget" } if ($sourceMissing -gt 0) { $why += "$sourceMissing object(s) had no source file below the workspace root" } Write-ALbuildLog -Level Warning ('Coverage HTML report is incomplete: ' + ($why -join '; ') + '. 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 SourceMissing = $sourceMissing Truncated = $truncated } } |