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. .PARAMETER OutputFolder Where to write the output files. Default: the current location. .OUTPUTS PSCustomObject: Summary, Objects, Outputs (the written file paths). #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $CoveragePath, [string] $WorkspaceRoot, [ValidateSet('ALbuildJson', 'Cobertura', 'Markdown')] [string[]] $Format = @('ALbuildJson'), [ValidateSet('Auto', 'Source', 'CoveredOnly')] [string] $DenominatorMode = 'Auto', [string] $OutputFolder = (Get-Location).Path ) 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)) { if (-not $execCache.ContainsKey($info.File)) { $execCache[$info.File] = @(Get-BcAlExecutableLines -Path $info.File) } $srcExec = $execCache[$info.File] 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' } } }) }) } $totalLines = ($objects | Measure-Object -Property totalExecutableLines -Sum).Sum $coveredLines = ($objects | Measure-Object -Property coveredLines -Sum).Sum 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) } if ($Format -contains 'Markdown') { $path = Join-Path $OutputFolder 'coverage.md' Write-CoverageMarkdown -Objects $objects -Summary $summary -Path $path $outputs.Add($path) } Write-ALbuildLog -Level Success ("Code coverage: {0}% ({1}/{2} lines across {3} object(s))." -f $summary.lineCoverage, $coveredLines, $totalLines, $objects.Count) return [PSCustomObject]@{ Summary = $summary; Objects = @($objects); Outputs = @($outputs) } } |