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 ExcludeNeverExecutedObjects Leave objects that no test touched out of the calculation, restoring the behaviour of module 2.23 and earlier. Business Central reports lines only for objects LOADED during the run, so by default such an object produced no rows, never entered the result, and could not lower the percentage - the figure answered "how much of the code the tests loaded is covered" rather than "how much of the app is covered". They are now counted with zero covered lines. Use this only where the workspace holds product apps that are deliberately not published into the test container; there the objects genuinely cannot be exercised and counting them would be unfair. Objects without executable lines are never added either way. .PARAMETER ExcludeProjects Project folder leaf names to leave out of the measurement entirely - the same names as the repo-root albuild.json 'excludeProjects' and Get-BcProjectBuildOrder. Omit it and the workspace-root albuild.json is read, so a repository that already excludes an app from the build excludes it from coverage too without any pipeline change. Pass a list to add the pipeline's own parameter on top. .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, [switch] $ExcludeNeverExecutedObjects, [string[]] $ExcludeProjects ) 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 } } } # Projects the repository excludes from the BUILD must not be measured either: they are never # published, so nothing can cover them, and counting them adds a block that is uncovered by # construction (businessdev.Api.Banking's 'demo' app: 21 objects, 1226 lines). Read from the same # repo-root albuild.json the build tasks use, so no pipeline has to opt in - callers can still pass an # explicit list to add the pipeline's own 'excludeProjects' parameter on top. # Built as a List and iterated with a plain foreach on purpose. An unbound [string[]] parameter is a # TYPED null, and '@($typedNull).Count' throws on Windows PowerShell 5.1 - the edition the DevOps tasks # actually run - while being perfectly happy on PowerShell 7. This exact shape has bitten this module # before, so the null never gets near a '.Count' here. $excludeList = New-Object System.Collections.Generic.List[string] foreach ($name in $ExcludeProjects) { if ("$name".Trim()) { $excludeList.Add("$name".Trim()) } } if (-not $PSBoundParameters.ContainsKey('ExcludeProjects') -and $WorkspaceRoot) { try { $projectConfig = Get-ALbuildProjectConfig -AppFolder $WorkspaceRoot -WorkspaceRoot $WorkspaceRoot if ($projectConfig.Has('ExcludeProjects')) { foreach ($name in $projectConfig.ExcludeProjects) { if ("$name".Trim()) { $excludeList.Add("$name".Trim()) } } } } catch { Write-ALbuildLog -Level Verbose "No albuild.json exclusions at '$WorkspaceRoot': $($_.Exception.Message)" } } if ($excludeList.Count -gt 0) { Write-ALbuildLog -Level Information "Coverage excludes project(s): $($excludeList -join ', ')." } $map = if ($WorkspaceRoot) { Get-BcAlObjectMap -WorkspaceRoot $WorkspaceRoot -ExcludeProjects $excludeList.ToArray() } 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 neverExecuted = $false lines = @($execNos | ForEach-Object { $h = if ($o.Lines.ContainsKey($_)) { $o.Lines[$_] } else { 0 } [PSCustomObject]@{ lineNo = $_; hits = $h; status = if ($coveredSet.ContainsKey($_)) { 'Covered' } else { 'NotCovered' } } }) }) } # Objects NO test ever touched. BC reports lines only for objects that were loaded during the run, so # an object nothing exercised produces no rows at all - and the loop above, which walks the reported # objects, never sees it. It then contributed neither covered lines nor executable ones, so it did not # lower the percentage by a single point: the gate measured "how much of the code the tests DID load is # covered", not "how much of the app is covered". Measured on businessdev.EInvoice: 43 of 75 product # objects were invisible this way. They belong in the denominator with zero covered lines - that is # precisely what a completely untested object is. # # Objects without executable lines (enums, interfaces, permission sets, plain table definitions) are # skipped: they cannot be covered, so listing them at 0% would invent a gap that does not exist. $neverExecuted = 0 if ($useSource -and $map.Count -gt 0 -and -not $ExcludeNeverExecutedObjects) { foreach ($key in ($map.Keys | Sort-Object)) { if ($byObject.ContainsKey($key)) { continue } $info = $map[$key] if (-not $info.File -or -not (Test-Path -LiteralPath $info.File)) { continue } $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 -eq 0) { continue } $neverExecuted++ $parts = $key -split ':' $relPath = if ($rootPath -and $info.File.StartsWith($rootPath)) { $info.File.Substring($rootPath.Length).TrimStart('\', '/').Replace('\', '/') } else { $info.File } $objects.Add([PSCustomObject]@{ objectType = $parts[0] objectId = [int] $parts[1] objectName = $info.Name filePath = $relPath denominator = 'Source' totalExecutableLines = $srcExec.Count coveredLines = 0 uncoveredLines = $srcExec lineCoverage = 0.0 neverExecuted = $true lines = @($srcExec | ForEach-Object { [PSCustomObject]@{ lineNo = $_; hits = 0; status = '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 # Objects no test loaded at all. Reported separately because "0% covered" and "never even reached" # are different findings: the second usually means a whole feature has no test, not a weak one. objectsNeverExecuted = $neverExecuted } 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) } } |