Skip to content

Commit

Permalink
Implement Cobertura coverage format (#2298)
Browse files Browse the repository at this point in the history
* wip: Implement Cobertura coverage format

* fix unit test

* revert editor settings change

* remove ?? operator

* fix attribute ordering

* make coverage report test work on all platforms

* Fix windows paths

* fix unit test for Windows paths

* kick the build

* re-implement Cobertura coverage report generation

* fix compatibility issues

* fix tests

* removing Cobertura from v4 parameter options

* fix compatibility with ReportGenerator

* Update src/functions/Coverage.ps1

Co-authored-by: Frode Flaten <[email protected]>

* fix whitespace

* fix output

* fix windows paths

* order packages,classes,methods by name

* change Cobertura DTD to loose

* Tune coverage report for performance

* Remove outdated condition

* Add Cobertura DTD file

* Apply suggestions from code review

Co-authored-by: Jakub Jareš <[email protected]>

* Fix typo and update JaCoCo starttime

* Fix tests

* Use epoch time for Cobertura and JaCoCo

* Update test

---------

Co-authored-by: Frode Flaten <[email protected]>
Co-authored-by: Jakub Jareš <[email protected]>
  • Loading branch information
3 people authored Oct 30, 2024
1 parent 2238989 commit e7a5259
Show file tree
Hide file tree
Showing 8 changed files with 417 additions and 17 deletions.
1 change: 1 addition & 0 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ if ($Clean) {
, ("$PSScriptRoot/src/schemas/JUnit4/*.xsd", "$PSScriptRoot/bin/schemas/JUnit4/")
, ("$PSScriptRoot/src/schemas/NUnit25/*.xsd", "$PSScriptRoot/bin/schemas/NUnit25/")
, ("$PSScriptRoot/src/schemas/NUnit3/*.xsd", "$PSScriptRoot/bin/schemas/NUnit3/")
, ("$PSScriptRoot/src/schemas/Cobertura/*.dtd", "$PSScriptRoot/bin/schemas/Cobertura/")
, ("$PSScriptRoot/src/schemas/JaCoCo/*.dtd", "$PSScriptRoot/bin/schemas/JaCoCo/")
, ("$PSScriptRoot/src/csharp/Pester/bin/$Configuration/net462/Pester.dll", "$PSScriptRoot/bin/bin/net462/")
, ("$PSScriptRoot/src/csharp/Pester/bin/$Configuration/net6.0/Pester.dll", "$PSScriptRoot/bin/bin/net6.0/")
Expand Down
1 change: 1 addition & 0 deletions publish/filesToPublish.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
'bin/net6.0/Pester.dll'
'en-US/about_Pester.help.txt'
'en-US/about_PesterConfiguration.help.txt'
'schemas/Cobertura/coverage-loose.dtd'
'schemas/JaCoCo/report.dtd'
'schemas/JUnit4/junit_schema_4.xsd'
'schemas/NUnit25/nunit_schema_2.5.xsd'
Expand Down
2 changes: 1 addition & 1 deletion src/csharp/Pester/CodeCoverageConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static CodeCoverageConfiguration ShallowClone(CodeCoverageConfiguration c
public CodeCoverageConfiguration() : base("Options to enable and configure Pester's code coverage feature.")
{
Enabled = new BoolOption("Enable CodeCoverage.", false);
OutputFormat = new StringOption("Format to use for code coverage report. Possible values: JaCoCo, CoverageGutters", "JaCoCo");
OutputFormat = new StringOption("Format to use for code coverage report. Possible values: JaCoCo, CoverageGutters, Cobertura", "JaCoCo");
OutputPath = new StringOption("Path relative to the current directory where code coverage report is saved.", "coverage.xml");
OutputEncoding = new StringOption("Encoding of the output file.", "UTF8");
Path = new StringArrayOption("Directories or files to be used for code coverage, by default the Path(s) from general settings are used, unless overridden here.", new string[0]);
Expand Down
2 changes: 1 addition & 1 deletion src/en-US/about_PesterConfiguration.help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ SECTIONS AND OPTIONS
Type: bool
Default value: $false

OutputFormat: Format to use for code coverage report. Possible values: JaCoCo, CoverageGutters
OutputFormat: Format to use for code coverage report. Possible values: JaCoCo, CoverageGutters, Cobertura
Type: string
Default value: 'JaCoCo'

Expand Down
14 changes: 7 additions & 7 deletions src/functions/Coverage.Plugin.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@

$configuration = $run.PluginConfiguration.Coverage

if ($configuration.OutputFormat -in 'JaCoCo', 'CoverageGutters') {
[xml] $jaCoCoReport = [xml] (Get-JaCoCoReportXml -CommandCoverage $breakpoints -TotalMilliseconds $totalMilliseconds -CoverageReport $coverageReport -Format $configuration.OutputFormat)
}
else {
throw "CodeCoverage.CoverageFormat '$($configuration.OutputFormat)' is not valid, please review your configuration."
$coverageXmlReport = switch ($configuration.OutputFormat) {
'JaCoCo' { [xml](Get-JaCoCoReportXml -CommandCoverage $breakpoints -TotalMilliseconds $totalMilliseconds -CoverageReport $coverageReport -Format 'JaCoCo') }
'CoverageGutters' { [xml](Get-JaCoCoReportXml -CommandCoverage $breakpoints -TotalMilliseconds $totalMilliseconds -CoverageReport $coverageReport -Format 'CoverageGutters') }
'Cobertura' { [xml](Get-CoberturaReportXml -CoverageReport $coverageReport -TotalMilliseconds $totalMilliseconds) }
default { throw "CodeCoverage.CoverageFormat '$($configuration.OutputFormat)' is not valid, please review your configuration." }
}

$settings = [Xml.XmlWriterSettings] @{
Expand All @@ -163,7 +163,7 @@
$stringWriter = [Pester.Factory]::CreateStringWriter()
$xmlWriter = [Xml.XmlWriter]::Create($stringWriter, $settings)

$jaCocoReport.WriteContentTo($xmlWriter)
$coverageXmlReport.WriteContentTo($xmlWriter)

$xmlWriter.Flush()
$stringWriter.Flush()
Expand Down Expand Up @@ -216,7 +216,7 @@
}

function Resolve-CodeCoverageConfiguration {
$supportedFormats = 'JaCoCo', 'CoverageGutters'
$supportedFormats = 'JaCoCo', 'CoverageGutters', 'Cobertura'
if ($PesterPreference.CodeCoverage.OutputFormat.Value -notin $supportedFormats) {
throw (Get-StringOptionErrorMessage -OptionPath 'CodeCoverage.OutputFormat' -SupportedValues $supportedFormats -Value $PesterPreference.CodeCoverage.OutputFormat.Value)
}
Expand Down
242 changes: 234 additions & 8 deletions src/functions/Coverage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -810,13 +810,12 @@ function Get-JaCoCoReportXml {

$isGutters = "CoverageGutters" -eq $Format

if ($null -eq $CoverageReport -or ('None' -eq $pester.Show) -or $CoverageReport.NumberOfCommandsAnalyzed -eq 0) {
if ($null -eq $CoverageReport -or $CoverageReport.NumberOfCommandsAnalyzed -eq 0) {
return [string]::Empty
}

$now = & $SafeCommands['Get-Date']
$nineteenSeventy = & $SafeCommands['Get-Date'] -Date "01/01/1970"
[long] $endTime = [math]::Floor((New-TimeSpan -start $nineteenSeventy -end $now).TotalMilliseconds)
# Report uses unix epoch time format (milliseconds since midnight 1/1/1970 UTC)
[long] $endTime = [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
[long] $startTime = [math]::Floor($endTime - $TotalMilliseconds)

$folderGroups = $CommandCoverage | & $SafeCommands["Group-Object"] -Property {
Expand Down Expand Up @@ -1038,6 +1037,224 @@ function Get-JaCoCoReportXml {
return $xml
}

function Get-CoberturaReportXml {
param (
[parameter(Mandatory = $true)]
[object] $CoverageReport,
[parameter(Mandatory = $true)]
[long] $TotalMilliseconds
)

if ($null -eq $CoverageReport -or $CoverageReport.NumberOfCommandsAnalyzed -eq 0) {
return [string]::Empty
}

# Report uses unix epoch time format (milliseconds since midnight 1/1/1970 UTC)
[long] $endTime = [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
[long] $startTime = [math]::Floor($endTime - $TotalMilliseconds)

$commonRoot = Get-CommonParentPath -Path $CoverageReport.AnalyzedFiles

$allLines = [System.Collections.Generic.List[object]]@()
$allLines.AddRange($CoverageReport.MissedCommands)
$allLines.AddRange($CoverageReport.HitCommands)
$packages = @{}
foreach ($command in $allLines) {
$package = & $SafeCommands["Split-Path"] $command.File -Parent
if (!$packages[$package]) {
$packages[$package] = @{
Classes = @{}
}
}

$class = $command.File
if (!$packages[$package].Classes[$class]) {
$packages[$package].Classes[$class] = @{
Methods = @{}
Lines = @{}
}
}

if (!$packages[$package].Classes[$class].Lines[$command.Line]) {
$packages[$package].Classes[$class].Lines[$command.Line] = [ordered]@{ number = $command.Line ; hits = 0 }
}
$packages[$package].Classes[$class].Lines[$command.Line].hits += $command.HitCount

$method = $command.Function
if (!$method) {
continue
}

if (!$packages[$package].Classes[$class].Methods[$method]) {
$packages[$package].Classes[$class].Methods[$method] = @{}
}

if (!$packages[$package].Classes[$class].Methods[$method][$command.Line]) {
$packages[$package].Classes[$class].Methods[$method][$command.Line] = [ordered]@{ number = $command.Line ; hits = 0 }
}
$packages[$package].Classes[$class].Methods[$method][$command.Line].hits += $command.HitCount
}

$packages = foreach ($packageGroup in $packages.GetEnumerator()) {
$classGroups = $packageGroup.Value.Classes
$classes = foreach ($classGroup in $classGroups.GetEnumerator()) {
$methodGroups = $classGroup.Value.Methods
$methods = foreach ($methodGroup in $methodGroups.GetEnumerator()) {
$lines = ([object[]]$methodGroup.Value.Values) | New-LineNode
$coveredLines = foreach ($line in $lines) { if (0 -lt $line.attributes.hits) { $line } }

$method = [ordered]@{
name = 'method'
attributes = [ordered]@{
name = $methodGroup.Name
signature = '()'
}
children = [ordered]@{
lines = $lines | & $SafeCommands["Sort-Object"] { [int]$_.attributes.number }
}
totalLines = $lines.Length
coveredLines = $coveredLines.Length
}

$method
}

$lines = ([object[]]$classGroup.Value.Lines.Values) | New-LineNode
$coveredLines = foreach ($line in $lines) { if (0 -lt $line.attributes.hits) { $line } }

$lineRate = Get-LineRate -CoveredLines $coveredLines.Length -TotalLines $lines.Length
$filename = $classGroup.Name.Substring($commonRoot.Length).Replace('\', '/').TrimStart('/')

$class = [ordered]@{
name = 'class'
attributes = [ordered]@{
name = (& $SafeCommands["Split-Path"] $classGroup.Name -Leaf)
filename = $filename
'line-rate' = $lineRate
'branch-rate' = 1
}
children = [ordered]@{
methods = $methods | & $SafeCommands["Sort-Object"] { $_.attributes.name }
lines = $lines | & $SafeCommands["Sort-Object"] { [int]$_.attributes.number }
}
totalLines = $lines.Length
coveredLines = $coveredLines.Length
}

$class
}

$totalLines = ($classes.totalLines | & $SafeCommands["Measure-Object"] -Sum).Sum
$coveredLines = ($classes.coveredLines | & $SafeCommands["Measure-Object"] -Sum).Sum
$lineRate = Get-LineRate -CoveredLines $coveredLines -TotalLines $totalLines
$packageName = $packageGroup.Name.Substring($commonRoot.Length).Replace('\', '/').TrimStart('/')

$package = [ordered]@{
name = 'package'
attributes = [ordered]@{
name = $packageName
'line-rate' = $lineRate
'branch-rate' = 0
}
children = [ordered]@{
classes = $classes | & $SafeCommands["Sort-Object"] { $_.attributes.name }
}
totalLines = $totalLines
coveredLines = $coveredLines
}

$package
}

$totalLines = ($packages.totalLines | & $SafeCommands["Measure-Object"] -Sum).Sum
$coveredLines = ($packages.coveredLines | & $SafeCommands["Measure-Object"] -Sum).Sum
$lineRate = Get-LineRate -CoveredLines $coveredLines -TotalLines $totalLines

$coverage = [ordered]@{
name = 'coverage'
attributes = [ordered]@{
'lines-valid' = $totalLines
'lines-covered' = $coveredLines
'line-rate' = $lineRate
'branches-valid' = 0
'branches-covered' = 0
'branch-rate' = 1
timestamp = $startTime
version = 0.1
}
children = [ordered]@{
sources = [ordered]@{
name = 'source'
value = $commonRoot.Replace('\', '/')
}
packages = $packages | & $SafeCommands["Sort-Object"] { $_.attributes.name }
}
}

$xmlDeclaration = '<?xml version="1.0" ?>'
$docType = '<!DOCTYPE coverage SYSTEM "coverage-loose.dtd">'
$coverageXml = ConvertTo-XmlElement -Node $coverage
$document = "$xmlDeclaration`n$docType`n$($coverageXml.OuterXml)"

$document
}

function New-LineNode {
param(
[parameter(Mandatory = $true, ValueFromPipeline = $true)] [object] $LineObject
)

process {
[ordered]@{
name = 'line'
attributes = $LineObject
}
}
}

function Get-LineRate {
param(
[parameter(Mandatory = $true)] [int] $CoveredLines,
[parameter(Mandatory = $true)] [int] $TotalLines
)

[double]$denominator = if ($TotalLines) { $TotalLines } else { 1 }

$CoveredLines / $denominator
}

function ConvertTo-XmlElement {
param(
[parameter(Mandatory = $true)] [object] $Node
)

$element = ([xml]"<$($Node.name)/>").DocumentElement
if ($node.attributes) {
$attributes = $node.attributes
foreach ($attr in $attributes.GetEnumerator()) {
$element.SetAttribute($attr.Name, $attr.Value)
}
}
if ($node.children) {
$children = $node.children
foreach ($child in $children.GetEnumerator()) {
$childElement = ([xml]"<$($child.Name)/>").DocumentElement
foreach ($value in $child.Value) {
$childXml = ConvertTo-XmlElement $value
$importedChildXml = $childElement.OwnerDocument.ImportNode($childXml, $true)
$null = $childElement.AppendChild($importedChildXml)
}
$importedChild = $element.OwnerDocument.ImportNode($childElement, $true)
$null = $element.AppendChild($importedChild)
}
}
if ($node.value) {
$element.InnerText = $node.value
}

$element
}

function Add-XmlElement {
param (
[parameter(Mandatory = $true)] [System.Xml.XmlNode] $Parent,
Expand All @@ -1046,14 +1263,23 @@ function Add-XmlElement {
)
$element = $Parent.AppendChild($Parent.OwnerDocument.CreateElement($Name))
if ($Attributes) {
foreach ($key in $Attributes.Keys) {
$attribute = $element.Attributes.Append($Parent.OwnerDocument.CreateAttribute($key))
$attribute.Value = $Attributes.$key
}
Add-XmlAttribute -Element $element -Attributes $Attributes
}
return $element
}

function Add-XmlAttribute {
param(
[parameter(Mandatory = $true)] [System.Xml.XmlNode] $Element,
[parameter(Mandatory = $true)] [System.Collections.IDictionary] $Attributes
)

foreach ($key in $Attributes.Keys) {
$attribute = $Element.Attributes.Append($Element.OwnerDocument.CreateAttribute($key))
$attribute.Value = $Attributes.$key
}
}

function Add-JaCoCoCounter {
param (
[parameter(Mandatory = $true)] [ValidateSet('Instruction', 'Line', 'Method', 'Class')] [string] $Type,
Expand Down
Loading

0 comments on commit e7a5259

Please sign in to comment.