diff --git a/build.ps1 b/build.ps1 index 254e8484a..622dfbe47 100644 --- a/build.ps1 +++ b/build.ps1 @@ -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/") diff --git a/publish/filesToPublish.ps1 b/publish/filesToPublish.ps1 index 6ae35efc8..ec216ff18 100644 --- a/publish/filesToPublish.ps1 +++ b/publish/filesToPublish.ps1 @@ -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' diff --git a/src/csharp/Pester/CodeCoverageConfiguration.cs b/src/csharp/Pester/CodeCoverageConfiguration.cs index b43d7ece3..1f1b3eda0 100644 --- a/src/csharp/Pester/CodeCoverageConfiguration.cs +++ b/src/csharp/Pester/CodeCoverageConfiguration.cs @@ -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]); diff --git a/src/en-US/about_PesterConfiguration.help.txt b/src/en-US/about_PesterConfiguration.help.txt index 3ebe44620..c00ff5b29 100644 --- a/src/en-US/about_PesterConfiguration.help.txt +++ b/src/en-US/about_PesterConfiguration.help.txt @@ -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' diff --git a/src/functions/Coverage.Plugin.ps1 b/src/functions/Coverage.Plugin.ps1 index 809e058c6..bf507a034 100644 --- a/src/functions/Coverage.Plugin.ps1 +++ b/src/functions/Coverage.Plugin.ps1 @@ -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] @{ @@ -163,7 +163,7 @@ $stringWriter = [Pester.Factory]::CreateStringWriter() $xmlWriter = [Xml.XmlWriter]::Create($stringWriter, $settings) - $jaCocoReport.WriteContentTo($xmlWriter) + $coverageXmlReport.WriteContentTo($xmlWriter) $xmlWriter.Flush() $stringWriter.Flush() @@ -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) } diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index fb8f0e877..739ed90fc 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -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 { @@ -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 = '' + $docType = '' + $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, @@ -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, diff --git a/src/schemas/Cobertura/coverage-loose.dtd b/src/schemas/Cobertura/coverage-loose.dtd new file mode 100644 index 000000000..35c8272d2 --- /dev/null +++ b/src/schemas/Cobertura/coverage-loose.dtd @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tst/functions/Coverage.Tests.ps1 b/tst/functions/Coverage.Tests.ps1 index 7b87327d8..5cdfd83db 100644 --- a/tst/functions/Coverage.Tests.ps1 +++ b/tst/functions/Coverage.Tests.ps1 @@ -494,6 +494,110 @@ InPesterModuleScope { ') } + It 'Cobertura report must be correct' { + [String]$coberturaReportXml = Get-CoberturaReportXml -TotalMilliseconds 10000 -CoverageReport $coverageReport + $coberturaReportXml = $coberturaReportXml -replace 'timestamp="[0-9]*"', 'timestamp=""' + $coberturaReportXml = $coberturaReportXml -replace "$([System.Environment]::NewLine)", '' + $coberturaReportXml = $coberturaReportXml.Replace($root, 'CommonRoot') + $coberturaReportXml = $coberturaReportXml.Replace($root.Replace('\', '/'), 'CommonRoot') + (Clear-WhiteSpace $coberturaReportXml) | Should -Be (Clear-WhiteSpace ' + + + + + CommonRoot + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ') + } + It 'JaCoCo returns empty string when there are 0 analyzed commands' { $coverageReport = [PSCustomObject] @{ NumberOfCommandsAnalyzed = 0 } [String]$jaCoCoReportXml = Get-JaCoCoReportXml -CommandCoverage @{} -TotalMilliseconds 10000 -CoverageReport $coverageReport -Format "CoverageGutters" @@ -501,6 +605,13 @@ InPesterModuleScope { $jaCoCoReportXml | Should -Be ([String]::Empty) } + It 'Cobertura returns empty string when there are 0 analyzed commands' { + $coverageReport = [PSCustomObject] @{ NumberOfCommandsAnalyzed = 0 } + [String]$coberturaReportXml = Get-CoberturaReportXml -CoverageReport $coverageReport -TotalMilliseconds 10000 + $coberturaReportXml | Should -Not -Be $null + $coberturaReportXml | Should -Be ([String]::Empty) + } + It 'Reports the right line numbers' { $coverageReport.HitCommands[$coverageReport.NumberOfCommandsExecuted - 1].Line | Should -Be 1 $coverageReport.HitCommands[$coverageReport.NumberOfCommandsExecuted - 1].StartLine | Should -Be 1