diff --git a/.pipelines/cosmos-pipelines.yml b/.pipelines/cosmos-pipelines.yml index 0f5a7eb427..88addc4a88 100644 --- a/.pipelines/cosmos-pipelines.yml +++ b/.pipelines/cosmos-pipelines.yml @@ -142,3 +142,18 @@ steps: inputs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + +# Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's +# 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 +# only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) +- task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' +- task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-cosmos' diff --git a/.pipelines/dwsql-pipelines.yml b/.pipelines/dwsql-pipelines.yml index d8fe8d0455..acafe817b8 100644 --- a/.pipelines/dwsql-pipelines.yml +++ b/.pipelines/dwsql-pipelines.yml @@ -146,6 +146,21 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + # Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's + # 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 + # only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-dwsql' + - job: windows pool: @@ -251,6 +266,20 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + # Publish the RAW cobertura as a uniquely-named pipeline artifact so the unit + # pipeline's 'Total Code Coverage' job can union the Windows job's coverage too. + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-dwsql-windows' + - task: CopyFiles@2 condition: eq(variables['publishverify'], 'Yes') displayName: 'Copy received files to Artifact Staging' diff --git a/.pipelines/mssql-pipelines.yml b/.pipelines/mssql-pipelines.yml index bdfb5e37e6..0086fed4bb 100644 --- a/.pipelines/mssql-pipelines.yml +++ b/.pipelines/mssql-pipelines.yml @@ -151,8 +151,20 @@ jobs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' - -# MsSql Integration Testing is split into two parallel jobs (~20 min each): + # Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's + # 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 + # only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-mssql' # # 1) windows_combined -> GraphQL, HotReload, REST, Unit, OpenApi, Auth, # Telemetry, and Caching tests. diff --git a/.pipelines/mysql-pipelines.yml b/.pipelines/mysql-pipelines.yml index d25fbabe7c..838a59b5ca 100644 --- a/.pipelines/mysql-pipelines.yml +++ b/.pipelines/mysql-pipelines.yml @@ -145,3 +145,18 @@ jobs: inputs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + + # Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's + # 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 + # only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-mysql' diff --git a/.pipelines/pg-pipelines.yml b/.pipelines/pg-pipelines.yml index 14f290ecbc..7d686fd90a 100644 --- a/.pipelines/pg-pipelines.yml +++ b/.pipelines/pg-pipelines.yml @@ -140,3 +140,18 @@ jobs: inputs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + + # Publish the RAW cobertura as a named pipeline artifact so the unit pipeline's + # 'Total Code Coverage' job can download and union it. (PublishCodeCoverageResults@1 + # only exposes an HTML report artifact, not the raw XML, so aggregation needs this.) + - task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' + - task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-pg' diff --git a/.pipelines/templates/build-pipelines.yml b/.pipelines/templates/build-pipelines.yml index e6e7ca3f13..40cb22ec2e 100644 --- a/.pipelines/templates/build-pipelines.yml +++ b/.pipelines/templates/build-pipelines.yml @@ -125,3 +125,18 @@ steps: inputs: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' + +# Publish this run's unit cobertura as a pipeline artifact so the +# "Total Code Coverage" job (unittest-pipelines.yml) can merge it with the +# database integration pipelines' coverage into one combined report. +- task: CopyFiles@2 + displayName: 'Stage unit coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' +- task: PublishPipelineArtifact@1 + displayName: 'Publish unit cobertura (coverage-unit)' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-unit' diff --git a/.pipelines/templates/mssql-test-steps.yml b/.pipelines/templates/mssql-test-steps.yml index 565273f27c..9a3efa436f 100644 --- a/.pipelines/templates/mssql-test-steps.yml +++ b/.pipelines/templates/mssql-test-steps.yml @@ -142,6 +142,20 @@ steps: codeCoverageTool: Cobertura summaryFileLocation: '$(Agent.TempDirectory)/**/*cobertura.xml' +# Publish the RAW cobertura as a uniquely-named pipeline artifact (suffixed per job) +# so the unit pipeline's 'Total Code Coverage' job can union THIS job's coverage too. +- task: CopyFiles@2 + displayName: 'Stage raw coverage for aggregation' + inputs: + sourceFolder: '$(Agent.TempDirectory)' + contents: '**/*.cobertura.xml' + targetFolder: '$(Build.ArtifactStagingDirectory)/coverage' +- task: PublishPipelineArtifact@1 + displayName: 'Publish coverage artifact for aggregation' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/coverage' + artifact: 'coverage-mssql${{ parameters.artifactSuffix }}' + - task: CopyFiles@2 condition: eq(variables['publishverify'], 'Yes') displayName: 'Copy received files to Artifact Staging' diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml index 56357a181c..31a09436b8 100644 --- a/.pipelines/unittest-pipelines.yml +++ b/.pipelines/unittest-pipelines.yml @@ -21,11 +21,113 @@ pr: - '*.md' - templates/** -pool: - vmImage: 'ubuntu-latest' # examples of other options: 'macOS-10.15', 'windows-2019' - variables: - template: templates/variables.yml -steps: -- template: templates/build-pipelines.yml +jobs: +- job: build + displayName: 'Build and Unit Tests' + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/build-pipelines.yml + +# --------------------------------------------------------------------------- +# Total Code Coverage (rolling dashboard, not a per-PR gate) +# --------------------------------------------------------------------------- +# Unions this run's unit coverage with the latest DB coverage and publishes one +# combined report. Best-effort (continueOnError) so it never blocks unit CI. +- job: total_code_coverage + displayName: 'Total Code Coverage (rolling dashboard)' + dependsOn: build + condition: succeededOrFailed() + continueOnError: true + pool: + vmImage: 'ubuntu-latest' + steps: + - checkout: self + + # This run's unit coverage (published by the build job above). + - task: DownloadPipelineArtifact@2 + displayName: 'Download unit coverage (this run)' + continueOnError: true + inputs: + source: 'current' + artifact: 'coverage-unit' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/unit' + + # DB coverage: each pipeline's numeric definition ID (Azure DevOps pipeline URL + # ...?definitionId=NN). Mapping: DwSql=1, MsSql=2, MySql=3, PgSql=4, Cosmos=6. + # (5 is THIS unit pipeline, so it is not downloaded here.) If a pipeline is + # recreated its ID changes and that DB silently drops from the union - the merge + # step's -ExpectedSources warning flags any source that produced no files. + # No 'artifact:' name is set so ALL of a run's coverage artifacts are pulled + # (some DBs publish one per job, e.g. MsSql linux + windows_combined + + # windows_configuration); the pattern keeps only raw cobertura for the merger. + - task: DownloadPipelineArtifact@2 + displayName: 'Download MsSql coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: '2' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/mssql' + - task: DownloadPipelineArtifact@2 + displayName: 'Download PostgreSql coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: '4' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/pg' + - task: DownloadPipelineArtifact@2 + displayName: 'Download MySql coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: '3' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/mysql' + - task: DownloadPipelineArtifact@2 + displayName: 'Download DwSql coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: '1' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/dwsql' + - task: DownloadPipelineArtifact@2 + displayName: 'Download CosmosDb coverage (latest)' + continueOnError: true + inputs: + source: 'specific' + project: '$(System.TeamProject)' + pipeline: '6' + runVersion: 'latest' + patterns: '**/*.cobertura.xml' + path: '$(Pipeline.Workspace)/db/cosmos' + + # Union-merge every downloaded cobertura and emit ONE combined Cobertura.xml + # (test projects excluded). Dependency-free - no ReportGenerator extension. + - task: PowerShell@2 + displayName: 'Merge cobertura (union) and emit combined report' + continueOnError: true + inputs: + filePath: 'scripts/merge-coverage.ps1' + arguments: '-Path "$(Pipeline.Workspace)" -OutFile "$(Build.ArtifactStagingDirectory)/merged/Cobertura.xml" -ExpectedSources unit,mssql,pg,mysql,dwsql,cosmos' + pwsh: true + + - task: PublishCodeCoverageResults@2 + displayName: 'Publish combined code coverage' + continueOnError: true + inputs: + summaryFileLocation: '$(Build.ArtifactStagingDirectory)/merged/Cobertura.xml' diff --git a/scripts/merge-coverage.ps1 b/scripts/merge-coverage.ps1 new file mode 100644 index 0000000000..ac6c93379b --- /dev/null +++ b/scripts/merge-coverage.ps1 @@ -0,0 +1,298 @@ +<# +.SYNOPSIS + Merges multiple Cobertura coverage reports (e.g. one per DAB pipeline: unit, mssql, + postgres, mysql, cosmos, dwsql) into a single combined line/branch coverage number. + +.DESCRIPTION + Azure DevOps' PublishCodeCoverageResults renders a per-pipeline coverage tab but does not + merge across pipelines. Download each pipeline run's raw *.cobertura.xml into one folder + (or pass -Files) and run this script to produce a single combined report. + + Merge semantics: + * LINE coverage is an EXACT union - a source line is covered if it is hit in ANY report. + * BRANCH coverage is an EXACT union WHEN per-edge detail is available. Coverlet may emit + children - one per + edge. When present (their count matches the aggregate denominator) we OR each edge's + covered flag across reports for a true union. When only the aggregate "(covered/total)" + is available we fall back to MAX(covered/total): a conservative FLOOR that never + over-counts but can under-count (e.g. (1/2)+(1/2) covering different edges is really 2/2 + but reports 1/2). Line % is always exact and is the headline metric. + + Zero external dependencies (no reportgenerator / no az CLI required). + +.PARAMETER Path + Folder to search recursively for *.cobertura.xml files. + +.PARAMETER Files + Explicit list of cobertura files to merge (overrides -Path). + +.EXAMPLE + ./merge-coverage.ps1 -Path C:\coverage-downloads + +.EXAMPLE + ./merge-coverage.ps1 -Files unit.cobertura.xml,mssql.cobertura.xml,pg.cobertura.xml +#> +[CmdletBinding()] +param( + [string]$Path, + [string[]]$Files, + # Package (assembly) names matching this regex are excluded (test projects by default). + [string]$ExcludePattern = '\.Tests$', + # When set, writes a merged Cobertura XML to this path (for PublishCodeCoverageResults). + [string]$OutFile, + # Optional path substrings (e.g. 'unit','mssql','cosmos'). A warning is printed for each + # that matches none of the discovered files, so a silently-missing source is noticeable. + [string[]]$ExpectedSources +) + +if ($Files) { + $reportFiles = $Files +} +elseif ($Path) { + $reportFiles = Get-ChildItem -Path $Path -Recurse -Filter *.cobertura.xml -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName +} +else { + Write-Error "Provide -Path or -Files a.xml,b.xml,..." + exit 1 +} + +$reportFiles = @($reportFiles | Where-Object { $_ -and (Test-Path $_) } | + ForEach-Object { (Resolve-Path -LiteralPath $_).Path } | Select-Object -Unique) +if ($reportFiles.Count -eq 0) { + Write-Error "No cobertura files found." + exit 1 # intentional: fail this task when there is nothing to merge (CI job is continueOnError) +} + +# Surface silently-missing sources: a DB whose artifact failed to download simply +# vanishes from the union, so warn loudly for each expected source with no files. +if ($ExpectedSources) { + foreach ($src in $ExpectedSources) { + if (-not ($reportFiles | Where-Object { $_ -like "*$src*" })) { + Write-Warning "Expected coverage source '$src' not found - it is ABSENT from the combined union." + } + } +} + +# key: "package|class|lineNumber" -> merged coverage point +$points = @{} +# "package|class" -> representative filename (first non-empty seen) +$classFile = @{} + +foreach ($rf in $reportFiles) { + try { + [xml]$xml = Get-Content -LiteralPath $rf -Raw + } + catch { + Write-Warning "Skipping unreadable file: $rf" + continue + } + + foreach ($pkg in $xml.coverage.packages.package) { + $pkgName = [string]$pkg.name + if ($ExcludePattern -and $pkgName -match $ExcludePattern) { continue } + foreach ($cls in $pkg.classes.class) { + $clsName = [string]$cls.name + $clsFile = [string]$cls.filename + if (-not $cls.lines) { continue } + $ckey = "$pkgName|$clsName" + if ($clsFile -and -not $classFile.ContainsKey($ckey)) { $classFile[$ckey] = $clsFile } + foreach ($ln in $cls.lines.line) { + if (-not $ln) { continue } + $num = [int]$ln.number + $hits = [long]$ln.hits + $isBranch = ([string]$ln.branch -eq 'true') + $condCov = 0 + $condTot = 0 + if ($isBranch -and ([string]$ln.'condition-coverage') -match '\((\d+)/(\d+)\)') { + $condCov = [int]$Matches[1] + $condTot = [int]$Matches[2] + } + + # Per-edge branch detail: coverlet may emit one + # per edge. When their count equals the aggregate denominator we can OR each edge's + # covered flag across reports for a TRUE union (instead of MAX-ing aggregate counts, + # which under-counts when two reports cover different edges of the same line). + $edges = $null + if ($isBranch -and $ln.conditions -and $ln.conditions.condition) { + $condEls = @($ln.conditions.condition) + if ($condTot -gt 0 -and $condEls.Count -eq $condTot) { + $edges = @{} + foreach ($c in $condEls) { + $covPct = 0 + if (([string]$c.coverage) -match '(\d+)') { $covPct = [int]$Matches[1] } + $edges[[int]$c.number] = ($covPct -ge 100) + } + } + } + + # Key by FQ class name (agent-path independent) + line number so the same + # source point unions across reports built on different agents (Windows vs Linux). + $key = "$pkgName|$clsName|$num" + if ($points.ContainsKey($key)) { + $e = $points[$key] + if ($hits -gt $e.Hits) { $e.Hits = $hits } + if ($isBranch) { + $e.IsBranch = $true + if ($condCov -gt $e.CondCov) { $e.CondCov = $condCov } + if ($condTot -gt $e.CondTot) { $e.CondTot = $condTot } + if ($edges) { + if (-not $e.Edges) { $e.Edges = @{} } + foreach ($n in $edges.Keys) { + $e.Edges[$n] = ([bool]$e.Edges[$n]) -or $edges[$n] + } + } + } + } + else { + $points[$key] = [pscustomobject]@{ + Package = $pkgName + Class = $clsName + Line = $num + Hits = $hits + IsBranch = $isBranch + CondCov = $condCov + CondTot = $condTot + Edges = $edges + } + } + } + } + } +} + +# Effective branch cov/tot per point. The per-edge union and the aggregate MAX are two +# INDEPENDENT lower bounds of the true union; take the max of each (still never over-counts, +# since each is <= the true union) so an aggregate-only report proving more is not discarded. +foreach ($e in $points.Values) { + if ($e.IsBranch -and $e.Edges -and $e.Edges.Count -gt 0) { + $cov = 0; foreach ($v in $e.Edges.Values) { if ($v) { $cov++ } } + $e | Add-Member -NotePropertyName EffTot -NotePropertyValue ([math]::Max($e.Edges.Count, $e.CondTot)) -Force + $e | Add-Member -NotePropertyName EffCov -NotePropertyValue ([math]::Max($cov, $e.CondCov)) -Force + } + elseif ($e.IsBranch) { + $e | Add-Member -NotePropertyName EffTot -NotePropertyValue $e.CondTot -Force + $e | Add-Member -NotePropertyName EffCov -NotePropertyValue $e.CondCov -Force + } + else { + $e | Add-Member -NotePropertyName EffTot -NotePropertyValue 0 -Force + $e | Add-Member -NotePropertyName EffCov -NotePropertyValue 0 -Force + } +} + +# Aggregate overall and per-assembly. +$byPkg = @{} +$totLines = 0; $covLines = 0; $totBr = 0; $covBr = 0 + +foreach ($e in $points.Values) { + $totLines++ + if ($e.Hits -gt 0) { $covLines++ } + if ($e.IsBranch) { $totBr += $e.EffTot; $covBr += $e.EffCov } + + if (-not $byPkg.ContainsKey($e.Package)) { + $byPkg[$e.Package] = [pscustomobject]@{ TL = 0; CL = 0; TB = 0; CB = 0 } + } + $p = $byPkg[$e.Package] + $p.TL++ + if ($e.Hits -gt 0) { $p.CL++ } + if ($e.IsBranch) { $p.TB += $e.EffTot; $p.CB += $e.EffCov } +} + +Write-Host "" +Write-Host "Merged $($reportFiles.Count) coverage file(s):" +$reportFiles | ForEach-Object { Write-Host " - $_" } +Write-Host "" +Write-Host ("{0,-45} {1,12} {2,14}" -f 'Assembly', 'Line %', 'Branch %') +Write-Host ("{0,-45} {1,12} {2,14}" -f ('-' * 40), '------', '--------') +foreach ($k in ($byPkg.Keys | Sort-Object)) { + $p = $byPkg[$k] + $lr = if ($p.TL) { [math]::Round($p.CL / $p.TL * 100, 1) } else { 0 } + $br = if ($p.TB) { [math]::Round($p.CB / $p.TB * 100, 1) } else { 0 } + Write-Host ("{0,-45} {1,9}% ({2}/{3}) {4,7}% ({5}/{6})" -f $k, $lr, $p.CL, $p.TL, $br, $p.CB, $p.TB) +} +Write-Host "" +$LR = if ($totLines) { [math]::Round($covLines / $totLines * 100, 2) } else { 0 } +$BR = if ($totBr) { [math]::Round($covBr / $totBr * 100, 2) } else { 0 } +Write-Host "==================== COMBINED (all pipelines) ====================" +Write-Host " LINE : $covLines / $totLines = $LR% (exact union)" +Write-Host " BRANCH : $covBr / $totBr = $BR% (exact where per-edge detail present, else floor)" +Write-Host "=================================================================" + +# --------------------------------------------------------------------------- +# Optionally emit a merged Cobertura XML (so PublishCodeCoverageResults can show +# the combined number in the Code Coverage tab - no ReportGenerator required). +# --------------------------------------------------------------------------- +if ($OutFile) { + function ConvertTo-XmlAttr([string]$s) { + if ($null -eq $s) { return '' } + return $s.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace('"', '"').Replace("'", ''') + } + + # Group merged points into package -> class -> lines. + $pkgTree = @{} + foreach ($e in $points.Values) { + if (-not $pkgTree.ContainsKey($e.Package)) { $pkgTree[$e.Package] = @{} } + $classes = $pkgTree[$e.Package] + if (-not $classes.ContainsKey($e.Class)) { $classes[$e.Class] = New-Object System.Collections.ArrayList } + [void]$classes[$e.Class].Add($e) + } + + $sb = New-Object System.Text.StringBuilder + [void]$sb.AppendLine('') + $lineRate = if ($totLines) { [math]::Round($covLines / $totLines, 4) } else { 0 } + $branchRate = if ($totBr) { [math]::Round($covBr / $totBr, 4) } else { 0 } + $ts = [int64]([datetime]::UtcNow - [datetime]'1970-01-01').TotalSeconds + [void]$sb.AppendLine("") + [void]$sb.AppendLine(' .') + [void]$sb.AppendLine(' ') + foreach ($pk in ($pkgTree.Keys | Sort-Object)) { + $classes = $pkgTree[$pk] + $pTL = 0; $pCL = 0; $pTB = 0; $pCB = 0 + foreach ($clist in $classes.Values) { + foreach ($e in $clist) { + $pTL++; if ($e.Hits -gt 0) { $pCL++ } + if ($e.IsBranch) { $pTB += $e.EffTot; $pCB += $e.EffCov } + } + } + $pLR = if ($pTL) { [math]::Round($pCL / $pTL, 4) } else { 0 } + $pBR = if ($pTB) { [math]::Round($pCB / $pTB, 4) } else { 0 } + [void]$sb.AppendLine((" " -f (ConvertTo-XmlAttr $pk), $pLR, $pBR)) + [void]$sb.AppendLine(' ') + foreach ($cn in ($classes.Keys | Sort-Object)) { + $clist = $classes[$cn] + $cTL = 0; $cCL = 0; $cTB = 0; $cCB = 0 + foreach ($e in $clist) { + $cTL++; if ($e.Hits -gt 0) { $cCL++ } + if ($e.IsBranch) { $cTB += $e.EffTot; $cCB += $e.EffCov } + } + $cLR = if ($cTL) { [math]::Round($cCL / $cTL, 4) } else { 0 } + $cBR = if ($cTB) { [math]::Round($cCB / $cTB, 4) } else { 0 } + $fn = '' + if ($classFile.ContainsKey("$pk|$cn")) { $fn = $classFile["$pk|$cn"] } + [void]$sb.AppendLine((" " -f (ConvertTo-XmlAttr $cn), (ConvertTo-XmlAttr $fn), $cLR, $cBR)) + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + foreach ($e in ($clist | Sort-Object Line)) { + if ($e.IsBranch -and $e.EffTot -gt 0) { + $pct = [math]::Round($e.EffCov / $e.EffTot * 100, 0) + [void]$sb.AppendLine((" " -f $e.Line, $e.Hits, $pct, $e.EffCov, $e.EffTot)) + } + else { + [void]$sb.AppendLine((" " -f $e.Line, $e.Hits)) + } + } + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + } + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine(' ') + } + [void]$sb.AppendLine(' ') + [void]$sb.AppendLine('') + + $dir = Split-Path -Parent $OutFile + if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + [System.IO.File]::WriteAllText($OutFile, $sb.ToString()) + Write-Host "" + Write-Host "Wrote merged Cobertura report: $OutFile" +}