diff --git a/src/Main.ps1 b/src/Main.ps1 index 670c9a1bf..543926650 100644 --- a/src/Main.ps1 +++ b/src/Main.ps1 @@ -1055,7 +1055,7 @@ function Invoke-Pester { } # this is here to support Pester test runner in VSCode. Don't use it unless you are prepared to get broken in the future. And if you decide to use it, let us know in https://github.com/pester/Pester/issues/2021 so we can warn you about removing this. - if (defined additionalPlugins) {$plugins += $script:additionalPlugins} + if (defined additionalPlugins) { $plugins += $script:additionalPlugins } $filter = New-FilterObject ` -Tag $PesterPreference.Filter.Tag.Value ` @@ -1154,10 +1154,13 @@ function Invoke-Pester { $configuration = $run.PluginConfiguration.Coverage if ("JaCoCo" -eq $configuration.OutputFormat -or "CoverageGutters" -eq $configuration.OutputFormat) { - [xml] $jaCoCoReport = [xml] (Get-JaCoCoReportXml -CommandCoverage $breakpoints -TotalMilliseconds $totalMilliseconds -CoverageReport $coverageReport -Format $configuration.OutputFormat) + [xml] $coverageXmlReport = [xml] (Get-JaCoCoReportXml -CommandCoverage $breakpoints -TotalMilliseconds $totalMilliseconds -CoverageReport $coverageReport -Format $configuration.OutputFormat) + } + elseif ("Cobertura" -eq $configuration.OutputFormat) { + [xml] $coverageXmlReport = [xml] (Get-CoberturaReportXml -CoverageReport $coverageReport -TotalMilliseconds $totalMilliseconds) } else { - throw "CodeCoverage.CoverageFormat must be 'JaCoCo' or 'CoverageGutters', but it was $($configuration.OutputFormat), please review your configuration." + throw "CodeCoverage.CoverageFormat must be 'JaCoCo', 'CoverageGutters', or 'Cobertura' but it was $($configuration.OutputFormat), please review your configuration." } $settings = [Xml.XmlWriterSettings] @{ @@ -1172,7 +1175,7 @@ function Invoke-Pester { $stringWriter = [Pester.Factory]::CreateStringWriter() $xmlWriter = [Xml.XmlWriter]::Create($stringWriter, $settings) - $jaCocoReport.WriteContentTo($xmlWriter) + $coverageXmlReport.WriteContentTo($xmlWriter) $xmlWriter.Flush() $stringWriter.Flush() diff --git a/src/Pester.RSpec.ps1 b/src/Pester.RSpec.ps1 index 2a09bcf40..5f5fc3e49 100644 --- a/src/Pester.RSpec.ps1 +++ b/src/Pester.RSpec.ps1 @@ -335,7 +335,7 @@ function New-PesterConfiguration { Enabled: Enable CodeCoverage. 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 Default value: 'JaCoCo' OutputPath: Path relative to the current directory where code coverage report is saved. diff --git a/src/csharp/Pester/CodeCoverageConfiguration.cs b/src/csharp/Pester/CodeCoverageConfiguration.cs index 6c2c140e2..cdaa61722 100644 --- a/src/csharp/Pester/CodeCoverageConfiguration.cs +++ b/src/csharp/Pester/CodeCoverageConfiguration.cs @@ -42,7 +42,7 @@ public static CodeCoverageConfiguration ShallowClone(CodeCoverageConfiguration c public CodeCoverageConfiguration() : base("CodeCoverage configuration.") { 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/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index 408707b90..438e0189a 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -1043,6 +1043,225 @@ function Get-JaCoCoReportXml { return $xml } +function Get-CoberturaReportXml { + param ( + [parameter(Mandatory = $true)] + [object] $CoverageReport, + [parameter(Mandatory = $true)] + [long] $TotalMilliseconds + ) + + if ($null -eq $CoverageReport -or ($pester.Show -eq [Pester.OutputTypes]::None) -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) + [long] $startTime = [math]::Floor($endTime - $TotalMilliseconds) + + $commonRoot = Get-CommonParentPath -Path $CoverageReport.AnalyzedFiles + + $allLines = [System.Collections.Generic.List[object]]@() + $null = $allLines.AddRange($CoverageReport.MissedCommands) + $null = $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 = $lines | & $SafeCommands["Where-Object"] { $_.attributes.hits -gt 0 } + + $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 = $lines | & $SafeCommands["Where-Object"] { $_.attributes.hits -gt 0 } + + $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$(([System.Xml.XmlElement]$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, @@ -1051,14 +1270,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/tst/functions/Coverage.Tests.ps1 b/tst/functions/Coverage.Tests.ps1 index 3a41303a6..3b09b4776 100644 --- a/tst/functions/Coverage.Tests.ps1 +++ b/tst/functions/Coverage.Tests.ps1 @@ -465,6 +465,104 @@ 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" @@ -472,6 +570,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