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. .PARAMETER IncludeNonDebuggable Count the bodies of [NonDebuggable] methods in the percentage, as uncovered lines. Business Central never reports those lines, so by default they stay out of the percentage and are listed separately (Unmeasured) with object, procedure and line count. Omitted, albuild.json 'coverage.includeNonDebuggable' decides. .PARAMETER Exclusions Lines that are provably unreachable in a test run (StartSession under test isolation, a background hand-over), each { object, procedure?, lines?, reason }. 'object' is "<Type> <Id>" or "<Type> <Name>"; 'lines' narrows the entry to source line ranges ("10-12, 20"); 'reason' is mandatory - an entry without one is ignored with a warning. Excluded lines leave the percentage and are listed separately (Excluded), never silently. Omitted, albuild.json 'coverage.exclusions' is read. .OUTPUTS PSCustomObject: Summary, Objects, Unmeasured, Excluded, 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, [switch] $IncludeNonDebuggable, [object[]] $Exclusions ) 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. $projectConfig = $null if ($WorkspaceRoot) { try { $projectConfig = Get-ALbuildProjectConfig -AppFolder $WorkspaceRoot -WorkspaceRoot $WorkspaceRoot } catch { Write-ALbuildLog -Level Verbose "No albuild.json at '$WorkspaceRoot': $($_.Exception.Message)" } } $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 $projectConfig -and $projectConfig.Has('ExcludeProjects')) { foreach ($name in $projectConfig.ExcludeProjects) { if ("$name".Trim()) { $excludeList.Add("$name".Trim()) } } } # albuild.json 'coverage' block: the defaults for -IncludeNonDebuggable and -Exclusions. $coverageConfig = if ($projectConfig -and $projectConfig.Has('Coverage')) { $projectConfig.Coverage } else { $null } $configValue = { param($name) if ($coverageConfig -and ($coverageConfig.PSObject.Properties.Name -contains $name)) { $coverageConfig.$name } else { $null } } $countNonDebuggable = if ($PSBoundParameters.ContainsKey('IncludeNonDebuggable')) { [bool]$IncludeNonDebuggable } else { [bool](& $configValue 'includeNonDebuggable') } $exclusionEntries = if ($PSBoundParameters.ContainsKey('Exclusions')) { $Exclusions } else { & $configValue 'exclusions' } $exclusionRules = @(ConvertTo-BcCoverageExclusionRule -Entry $exclusionEntries) # The exclusions are logged by Get-BcAlObjectMap, which is the only place that knows which of them # matched a project at all - a name that matches nothing is a no-op, not an applied exclusion. $map = if ($WorkspaceRoot) { Get-BcAlObjectMap -WorkspaceRoot $WorkspaceRoot -ExcludeProjects $excludeList.ToArray() } else { @{} } $rootPath = if ($WorkspaceRoot) { (Resolve-Path -LiteralPath $WorkspaceRoot).Path } else { '' } if ($WorkspaceRoot -and $map.Count -eq 0) { # Not an error - but it is the one state in which every figure below is zero, so it must be said # out loud rather than left to be inferred from a 0% report. Write-ALbuildLog -Level Warning ("No measurable product object found under '{0}': every app there is a declared test app, is excluded, or has no AL source. " -f $rootPath + 'Coverage is reported as 0 object(s).') } # Honest denominator: BC's export lists only Covered/Partial lines, so the raw CSV always reads ~100%. # In Source mode the executable lines come from the source (Get-BcAlSourceModel), the covered ones from # the CSV; the union guarantees covered <= executable. $useSource = $DenominatorMode -ne 'CoveredOnly' -and [bool]$WorkspaceRoot $modelCache = @{} $getModel = { param($info) if (-not $info -or -not $info.File -or -not (Test-Path -LiteralPath $info.File)) { return $null } # Symbols from the owning app's app.json, which the compile step writes before the tests run, so a # branch this build did not compile is not counted. Cached per file AND symbol set. $appSymbols = @((Resolve-BcAlAppContext -Path $info.File).PreprocessorSymbols) $modelKey = "$($info.File)|$($appSymbols -join ',')" if (-not $modelCache.ContainsKey($modelKey)) { $modelCache[$modelKey] = Get-BcAlSourceModel -Path $info.File -Symbols $appSymbols } return $modelCache[$modelKey] } $objects = [System.Collections.Generic.List[object]]::new() $unmeasured = [System.Collections.Generic.List[object]]::new() $excluded = [System.Collections.Generic.List[object]]::new() # One coverage record per object; $hits maps line -> hit count from the CSV (empty for never-executed). $newRecord = { param([string] $type, [int] $id, $info, [hashtable] $hits, [bool] $neverExecuted) $name = if ($info) { $info.Name } else { '' } $relPath = if ($info -and $rootPath -and $info.File.StartsWith($rootPath)) { $info.File.Substring($rootPath.Length).TrimStart('\', '/').Replace('\', '/') } elseif ($info) { $info.File } else { '' } $isCovered = { param($n) $hits.ContainsKey($n) -and $hits[$n] -gt 0 } $model = if ($useSource) { & $getModel $info } else { $null } $sourced = $null -ne $model -and (@($model.ExecutableLines).Count + @($model.UnmeasuredLines).Count) -gt 0 if ($neverExecuted -and -not $sourced) { return $null } $measured = [System.Collections.Generic.SortedSet[int]]::new() $unmeasuredNos = [System.Collections.Generic.SortedSet[int]]::new() $excludedNos = [System.Collections.Generic.SortedSet[int]]::new() $procedures = @() $continuations = @() if ($sourced) { foreach ($n in $model.ExecutableLines) { [void]$measured.Add([int]$n) } foreach ($n in $hits.Keys) { if (& $isCovered $n) { [void]$measured.Add([int]$n) } } # covered is always counted foreach ($n in $model.UnmeasuredLines) { if ($countNonDebuggable) { [void]$measured.Add([int]$n) } else { [void]$unmeasuredNos.Add([int]$n) } } $procedures = @($model.Procedures) foreach ($rule in $exclusionRules) { if (-not (Test-BcCoverageExclusionRuleObject -Rule $rule -Type $type -Id $id -Name $name)) { continue } $candidates = if ($rule.Procedure) { @($procedures | Where-Object { $_.Name -ieq $rule.Procedure } | ForEach-Object { $_.Lines }) } else { @($measured) + @($unmeasuredNos) } $ruleLines = @($candidates | Where-Object { $null -eq $rule.Lines -or $rule.Lines.Contains([int]$_) } | Sort-Object -Unique) if ($ruleLines.Count -eq 0) { continue } $rule.Matched = $true foreach ($n in $ruleLines) { [void]$excludedNos.Add([int]$n); [void]$measured.Remove([int]$n); [void]$unmeasuredNos.Remove([int]$n) } $excluded.Add([PSCustomObject]@{ objectType = $type objectId = $id objectName = $name filePath = $relPath procedure = $rule.Procedure lines = [int[]]$ruleLines lineCount = $ruleLines.Count coveredLines = @($ruleLines | Where-Object { & $isCovered $_ }).Count reason = $rule.Reason }) } foreach ($p in $procedures) { if (-not $p.NonDebuggable) { continue } $counted = @($p.Lines | Where-Object { $(if ($countNonDebuggable) { $measured.Contains([int]$_) } else { $unmeasuredNos.Contains([int]$_) }) }) if ($counted.Count -eq 0) { continue } $unmeasured.Add([PSCustomObject]@{ objectType = $type objectId = $id objectName = $name filePath = $relPath procedure = $p.Name line = $p.Line endLine = $p.EndLine lines = $counted.Count reason = 'NonDebuggable' included = $countNonDebuggable }) } # A continuation line shares the status of the line its statement starts on. $continuations = @($model.StatementOf.Keys | Sort-Object | Where-Object { $measured.Contains([int]$model.StatementOf[$_]) } | ForEach-Object { [PSCustomObject]@{ lineNo = [int]$_; statementLine = [int]$model.StatementOf[$_] } }) } else { foreach ($n in $hits.Keys) { [void]$measured.Add([int]$n) } # CoveredOnly: the CSV lines themselves } $covered = @($measured | Where-Object { & $isCovered $_ }).Count $procedureRecords = @(foreach ($p in $procedures) { $pl = @($p.Lines | Where-Object { $measured.Contains([int]$_) }) $pc = @($pl | Where-Object { & $isCovered $_ }).Count [PSCustomObject]@{ name = $p.Name kind = $p.Kind line = $p.Line endLine = $p.EndLine nonDebuggable = [bool]$p.NonDebuggable measured = $pl.Count -gt 0 executableLines = $pl.Count coveredLines = $pc lineCoverage = Get-Pct $pc $pl.Count uncoveredLines = [int[]]@($pl | Where-Object { -not (& $isCovered $_) }) } }) return [PSCustomObject]@{ objectType = $type objectId = $id objectName = $name filePath = $relPath denominator = if ($sourced) { 'Source' } else { 'CoveredOnly' } totalExecutableLines = $measured.Count coveredLines = $covered uncoveredLines = [int[]]@($measured | Where-Object { -not (& $isCovered $_) }) lineCoverage = Get-Pct $covered $measured.Count neverExecuted = $neverExecuted unmeasuredLines = [int[]]@($unmeasuredNos) excludedLines = [int[]]@($excludedNos) procedures = $procedureRecords continuations = $continuations lines = @($measured | ForEach-Object { $h = if ($hits.ContainsKey($_)) { $hits[$_] } else { 0 } [PSCustomObject]@{ lineNo = $_; hits = $h; status = if ($h -gt 0) { 'Covered' } else { 'NotCovered' } } }) } } 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 $record = & $newRecord $o.Type $o.Id $info $o.Lines $false if ($record) { $objects.Add($record) } } # Objects NO test ever touched. BC reports nothing for an object that was never loaded, so it would # contribute neither covered nor executable lines and could not lower the percentage. Measured on # businessdev.EInvoice: 43 of 75 product objects were invisible this way. They count with zero covered # lines. Objects without executable lines (enums, interfaces, plain tables) are skipped. $neverExecuted = 0 if ($useSource -and $map.Count -gt 0 -and -not $ExcludeNeverExecutedObjects) { foreach ($key in ($map.Keys | Sort-Object)) { if ($byObject.ContainsKey($key)) { continue } $parts = $key -split ':' $record = & $newRecord $parts[0] ([int]$parts[1]) $map[$key] @{} $true if (-not $record) { continue } if ([int]$record.totalExecutableLines -eq 0 -and @($record.unmeasuredLines).Count -eq 0 -and @($record.excludedLines).Count -eq 0) { continue } $neverExecuted++ $objects.Add($record) } } foreach ($rule in $exclusionRules) { if (-not $rule.Matched) { Write-ALbuildLog -Level Warning ("Coverage exclusion '{0}' matches no executable line; nothing was excluded for it. Correct or remove the entry." -f $rule.Label) } } # Summed by hand rather than with Measure-Object: over an EMPTY set Measure-Object emits nothing, and # reading .Sum then throws under StrictMode. $totalLines = 0 $coveredLines = 0 foreach ($o in $objects) { $totalLines += [int] $o.totalExecutableLines $coveredLines += [int] $o.coveredLines } $unmeasuredLineCount = 0 foreach ($u in $unmeasured) { $unmeasuredLineCount += [int]$u.lines } $excludedLineCount = 0 foreach ($x in $excluded) { $excludedLineCount += [int]$x.lineCount } $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 # "0% covered" and "never even reached" are different findings. objectsNeverExecuted = $neverExecuted # [NonDebuggable] bodies, which BC never reports. In the percentage only when nonDebuggableIncluded. unmeasuredProcedures = $unmeasured.Count unmeasuredLines = $unmeasuredLineCount nonDebuggableIncluded = $countNonDebuggable # Lines albuild.json declares unreachable, each entry with its reason. Never in the percentage. excludedLines = $excludedLineCount exclusionCount = $excluded.Count } 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 unmeasured = @($unmeasured) excluded = @($excluded) objects = @($objects) } $path = Join-Path $OutputFolder 'coverage-summary.json' $doc | ConvertTo-Json -Depth 10 | 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 -Unmeasured $unmeasured -Excluded $excluded -SourceRoot $rootPath -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 -Unmeasured $unmeasured -Excluded $excluded -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 ($unmeasured.Count -gt 0) { $state = if ($countNonDebuggable) { 'counted as uncovered (coverage.includeNonDebuggable)' } else { 'NOT in the percentage' } Write-ALbuildLog -Level Information ("Not measurable: {0} [NonDebuggable] procedure(s) with {1} line(s), {2}." -f $unmeasured.Count, $unmeasuredLineCount, $state) } if ($excluded.Count -gt 0) { Write-ALbuildLog -Level Information ("Excluded by albuild.json: {0} line(s) from {1} entr(y/ies), each with its reason in the report." -f $excludedLineCount, $excluded.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); Unmeasured = @($unmeasured); Excluded = @($excluded); TestQuality = $quality; Outputs = @($outputs) } } |