Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Cobertura coverage format #2298

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/Main.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
Expand Down Expand Up @@ -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] @{
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/Pester.RSpec.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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 @@ -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]);
Expand Down
236 changes: 232 additions & 4 deletions src/functions/Coverage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<?xml version="1.0" ?>'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Include encoding="UTF-8" like JaCoCo? Though I just noticed both hardcode this, while we actually make this configurable in CodeCoverage.OutputEncoding 😬

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting... so what should we do? Hard-code or try to get it from the settings?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd hardcode for now to make sure it's compatible with Cobertura-tools. At least we'd be consistent and support unicode-characters properly.

Then we'll fix both later in a separate issue as it's more a xml-thing.

$docType = '<!DOCTYPE coverage SYSTEM "https://raw.githubusercontent.com/cobertura/cobertura/master/cobertura/src/site/htdocs/xml/coverage-loose.dtd">'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nohwnd: Should we point to this external URL or just coverage-loose.dtd and include the DTD-file in our build like the other output formats? I'm not familiar with the history of that decision.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to do it whichever way is the preferred way. Would you like me to make that change or do we need to consult other maintainers?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just a contributor, so lets wait on Jakub :)

$coverageXml = ConvertTo-XmlElement -Node $coverage
$document = "$xmlDeclaration`n$docType`n$(([System.Xml.XmlElement]$coverageXml).OuterXml)"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Is the cast necessary?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... maybe not. I'll try without it.


$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 }

joeskeen marked this conversation as resolved.
Show resolved Hide resolved
$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 @@ -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,
Expand Down