Modules/businessdev.ALbuild.Apps/Public/Convert-BcCodeCoverage.ps1
|
function Convert-BcCodeCoverage { <# .SYNOPSIS Converts raw Business Central code coverage (.dat CSV from Invoke-BcContainerTest -CodeCoverage) into ALbuild JSON, Cobertura XML and/or a Markdown summary. .DESCRIPTION The raw rows are VariableText CSV: ObjectType, ObjectID, LineNo, CoverageStatus, NoOfHits. This aggregates them per object (max hit count per line - union semantics across chunks), attaches each object to its source file via the workspace AL object map, computes line coverage, and emits the requested formats. With -WorkspaceRoot the result is restricted to objects whose source is in the workspace (your app code); without it, every tracked object is included. .PARAMETER CoveragePath A folder of raw *.dat files (or a single .dat file) produced by the coverage-enabled test run. .PARAMETER WorkspaceRoot AL source root. Restricts/maps coverage to your app objects and sets file paths. .PARAMETER Format One or more of ALbuildJson, Cobertura, Markdown, Html. 'Html' writes a self-contained report to '<OutputFolder>/html/index.html' - the directory the Azure DevOps coverage tab renders. The '##vso[codecoverage.publish]' logging command uploads that directory but does NOT generate it, which is why the tab stayed empty without this format. .PARAMETER OutputFolder Where to write the output files. Default: the current location. .PARAMETER TestQuality The result of Get-BcTestQuality, included in the Markdown and HTML reports. Coverage measures which lines RAN; it cannot tell whether a wrong result would have been caught, because a [Test] method that asserts nothing still covers every line it touches. Reporting the percentage on its own therefore overstates what the suite proves, so the assertion figures belong next to it. Omit it and, when -WorkspaceRoot is known, it is computed automatically - the report carries the caveat by default rather than only when someone remembers to ask. Pass a value to avoid scanning the workspace twice when the caller already has it. .OUTPUTS PSCustomObject: Summary, Objects, TestQuality, Outputs (the written file paths). #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $CoveragePath, [string] $WorkspaceRoot, [ValidateSet('ALbuildJson', 'Cobertura', 'Markdown', 'Html')] [string[]] $Format = @('ALbuildJson'), [ValidateSet('Auto', 'Source', 'CoveredOnly')] [string] $DenominatorMode = 'Auto', [string] $OutputFolder = (Get-Location).Path, [object] $TestQuality ) function Get-Pct([int] $covered, [int] $total) { if ($total -le 0) { return 0.0 } return [math]::Round(($covered / $total) * 100, 2) } $datFiles = @(if (Test-Path -LiteralPath $CoveragePath -PathType Container) { Get-ChildItem -LiteralPath $CoveragePath -Filter '*.dat' -File -Recurse } else { Get-Item -LiteralPath $CoveragePath }) if ($datFiles.Count -eq 0) { throw "No coverage .dat files found at '$CoveragePath'." } # Parse rows: Type, Id, LineNo, Status, Hits. $byObject = @{} foreach ($file in $datFiles) { foreach ($line in (Get-Content -LiteralPath $file.FullName -ErrorAction SilentlyContinue)) { $p = $line -split ',' if ($p.Count -lt 5 -or $p[1] -notmatch '^\d+$' -or $p[2] -notmatch '^\d+$') { continue } $type = (Get-Culture).TextInfo.ToTitleCase($p[0].Trim().ToLowerInvariant()) $id = [int]$p[1]; $lineNo = [int]$p[2]; $hits = [int]$p[4] $key = "$type`:$id" if (-not $byObject.ContainsKey($key)) { $byObject[$key] = @{ Type = $type; Id = $id; Lines = @{} } } $lines = $byObject[$key].Lines if (-not $lines.ContainsKey($lineNo) -or $hits -gt $lines[$lineNo]) { $lines[$lineNo] = $hits } } } $map = if ($WorkspaceRoot) { Get-BcAlObjectMap -WorkspaceRoot $WorkspaceRoot } else { @{} } $rootPath = if ($WorkspaceRoot) { (Resolve-Path -LiteralPath $WorkspaceRoot).Path } else { '' } # Honest denominator: BC's export lists only Covered/Partial lines, so the raw CSV always reads ~100%. # In Source mode (default when the object's AL source is in the workspace) the *total* executable lines come # from the source instead; covered lines come from the CSV; the union guarantees covered <= executable. $useSource = $DenominatorMode -ne 'CoveredOnly' -and [bool]$WorkspaceRoot $execCache = @{} $objects = [System.Collections.Generic.List[object]]::new() foreach ($key in ($byObject.Keys | Sort-Object)) { $o = $byObject[$key] $info = $map[$key] if ($WorkspaceRoot -and -not $info) { continue } # only our app's objects when a workspace is given $coveredNos = @($o.Lines.Keys | Where-Object { $o.Lines[$_] -gt 0 } | Sort-Object) # Determine this object's executable-line universe (the denominator). $sourced = $false $execNos = $coveredNos if ($useSource -and $info -and $info.File -and (Test-Path -LiteralPath $info.File)) { # Preprocessor symbols come from the owning app's app.json, which the compile step writes # before the tests run - so the denominator matches what alc.exe actually compiled, and lines # in a branch this build did not compile are not counted as missing coverage. Cached per app # folder by Resolve-BcAlAppContext; the exec cache is keyed by file AND symbols so a workspace # holding apps with different symbol sets cannot reuse the wrong result. $appSymbols = @((Resolve-BcAlAppContext -Path $info.File).PreprocessorSymbols) $execKey = "$($info.File)|$($appSymbols -join ',')" if (-not $execCache.ContainsKey($execKey)) { $execCache[$execKey] = @(Get-BcAlExecutableLines -Path $info.File -Symbols $appSymbols) } $srcExec = $execCache[$execKey] if ($srcExec.Count -gt 0) { $execNos = @(($srcExec + $coveredNos) | Sort-Object -Unique) # union: covered is always counted $sourced = $true } } # CoveredOnly fallback (or no source): denominator is the CSV lines themselves. if (-not $sourced) { $execNos = @($o.Lines.Keys | Sort-Object) } $coveredSet = @{}; foreach ($n in $coveredNos) { $coveredSet[$n] = $true } $total = @($execNos).Count $covered = @($execNos | Where-Object { $coveredSet.ContainsKey($_) }).Count $relPath = if ($info -and $rootPath -and $info.File.StartsWith($rootPath)) { $info.File.Substring($rootPath.Length).TrimStart('\', '/').Replace('\', '/') } elseif ($info) { $info.File } else { '' } $objects.Add([PSCustomObject]@{ objectType = $o.Type objectId = $o.Id objectName = if ($info) { $info.Name } else { '' } filePath = $relPath denominator = if ($sourced) { 'Source' } else { 'CoveredOnly' } totalExecutableLines = $total coveredLines = $covered uncoveredLines = @($execNos | Where-Object { -not $coveredSet.ContainsKey($_) }) lineCoverage = Get-Pct $covered $total lines = @($execNos | ForEach-Object { $h = if ($o.Lines.ContainsKey($_)) { $o.Lines[$_] } else { 0 } [PSCustomObject]@{ lineNo = $_; hits = $h; status = if ($coveredSet.ContainsKey($_)) { 'Covered' } else { 'NotCovered' } } }) }) } # Summed by hand rather than with Measure-Object: over an EMPTY set Measure-Object emits nothing, so # reading .Sum throws under StrictMode and an empty coverage folder crashed the whole conversion # instead of reporting 0%. A plain loop has no such edge case. $totalLines = 0 $coveredLines = 0 foreach ($o in $objects) { $totalLines += [int] $o.totalExecutableLines $coveredLines += [int] $o.coveredLines } if ($null -eq $totalLines) { $totalLines = 0 }; if ($null -eq $coveredLines) { $coveredLines = 0 } $sourcedCount = @($objects | Where-Object { $_.denominator -eq 'Source' }).Count $summary = [PSCustomObject]@{ lineCoverage = Get-Pct $coveredLines $totalLines coveredLines = $coveredLines totalExecutableLines = $totalLines objectCount = $objects.Count denominatorMode = if ($sourcedCount -eq $objects.Count -and $objects.Count -gt 0) { 'Source' } elseif ($sourcedCount -gt 0) { 'Mixed' } else { 'CoveredOnly' } sourcedObjectCount = $sourcedCount } New-Item -ItemType Directory -Force -Path $OutputFolder | Out-Null $outputs = [System.Collections.Generic.List[string]]::new() if ($Format -contains 'ALbuildJson') { $doc = [PSCustomObject]@{ schemaVersion = '1.0'; tool = 'ALbuild'; operation = 'coverage.collect' createdAt = (Get-Date).ToUniversalTime().ToString('o') summary = $summary objects = @($objects) } $path = Join-Path $OutputFolder 'coverage-summary.json' $doc | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $path -Encoding UTF8 $outputs.Add($path) } if ($Format -contains 'Cobertura') { $path = Join-Path $OutputFolder 'cobertura.xml' Write-CoberturaXml -Objects $objects -Summary $summary -SourceRoot $rootPath -Path $path $outputs.Add($path) } # Test quality travels WITH the coverage figure, because the figure alone overstates what the suite # proves: a [Test] method that asserts nothing covers every line it touches. Scanning is best-effort - # like the HTML report, this is reporting, and it must never take down a run that measured correctly. $quality = $TestQuality if ($null -eq $quality -and $rootPath) { try { $quality = Get-BcTestQuality -WorkspaceRoot $rootPath } catch { $quality = $null Write-ALbuildLog -Level Warning ("Could not assess test quality for the coverage report ({0}). " -f $_.Exception.Message + 'The coverage figures are unaffected.') } } if ($Format -contains 'Markdown') { $path = Join-Path $OutputFolder 'coverage.md' Write-CoverageMarkdown -Objects $objects -Summary $summary -Path $path -TestQuality $quality $outputs.Add($path) } if ($Format -contains 'Html') { # Its own folder: the Azure DevOps coverage tab is pointed at a report DIRECTORY, not a file. $path = Join-Path (Join-Path $OutputFolder 'html') 'index.html' Write-CoverageHtml -Objects $objects -Summary $summary -SourceRoot $rootPath -Path $path -TestQuality $quality | Out-Null $outputs.Add($path) } Write-ALbuildLog -Level Success ("Code coverage: {0}% ({1}/{2} lines across {3} object(s))." -f $summary.lineCoverage, $coveredLines, $totalLines, $objects.Count) if ($null -ne $quality) { Write-ALbuildLog -Level Information ("Test quality: {0}/{1} test(s) assert (score {2}); {3} without assertions, {4} empty." -f ` $quality.Summary.testsWithAssertions, $quality.Summary.testCount, $quality.Summary.qualityScore, $quality.Summary.testsWithoutAssertions, $quality.Summary.emptyTests) } return [PSCustomObject]@{ Summary = $summary; Objects = @($objects); TestQuality = $quality; Outputs = @($outputs) } } |