diff --git a/build.psm1 b/build.psm1
index b6dba8d0b2..8be3abb1aa 100644
--- a/build.psm1
+++ b/build.psm1
@@ -2414,6 +2414,416 @@ function Copy-PSGalleryModules
}
}
+function Merge-TestLogs
+{
+ [CmdletBinding()]
+ param (
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({Test-Path $_})]
+ [string]$XUnitLogPath,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateScript({Test-Path $_})]
+ [string[]]$NUnitLogPath,
+
+ [Parameter()]
+ [ValidateScript({Test-Path $_})]
+ [string[]]$AdditionalXUnitLogPath,
+
+ [Parameter()]
+ [string]$OutputLogPath
+ )
+
+ # Convert all the NUnit logs into single object
+ $convertedNUnit = ConvertFrom-PesterLog -logFile $NUnitLogPath
+
+ $xunit = [xml] (Get-Content $XUnitLogPath -ReadCount 0 -Raw)
+
+ $strBld = [System.Text.StringBuilder]::new($xunit.assemblies.InnerXml)
+
+ foreach($assembly in $convertedNUnit.assembly)
+ {
+ $strBld.Append($assembly.ToString()) | Out-Null
+ }
+
+ foreach($path in $AdditionalXUnitLogPath)
+ {
+ $addXunit = [xml] (Get-Content $path -ReadCount 0 -Raw)
+ $strBld.Append($addXunit.assemblies.InnerXml) | Out-Null
+ }
+
+ $xunit.assemblies.InnerXml = $strBld.ToString()
+ $xunit.Save($OutputLogPath)
+}
+
+function ConvertFrom-PesterLog {
+ [CmdletBinding()]
+ param (
+ [Parameter(ValueFromPipeline = $true, Mandatory = $true, Position = 0)]
+ [string[]]$Logfile,
+ [Parameter()][switch]$IncludeEmpty,
+ [Parameter()][switch]$MultipleLog
+ )
+ <#
+Convert our test logs to
+xunit schema - top level assemblies
+Pester conversion
+foreach $r in "test-results"."test-suite".results."test-suite"
+assembly
+ name = $r.Description
+ config-file = log file (this is the only way we can determine between admin/nonadmin log)
+ test-framework = Pester
+ environment = top-level "test-results.environment.platform
+ run-date = date (doesn't exist in pester except for beginning)
+ run-time = time
+ time =
+#>
+
+ BEGIN {
+ # CLASSES
+ class assemblies {
+ # attributes
+ [datetime]$timestamp
+ # child elements
+ [System.Collections.Generic.List[testAssembly]]$assembly
+ assemblies() {
+ $this.timestamp = [datetime]::now
+ $this.assembly = [System.Collections.Generic.List[testAssembly]]::new()
+ }
+ static [assemblies] op_Addition([assemblies]$ls, [assemblies]$rs) {
+ $newAssembly = [assemblies]::new()
+ $newAssembly.assembly.AddRange($ls.assembly)
+ $newAssembly.assembly.AddRange($rs.assembly)
+ return $newAssembly
+ }
+ [string]ToString() {
+ $sb = [text.stringbuilder]::new()
+ $sb.AppendLine('' -f $this.timestamp)
+ foreach ( $a in $this.assembly ) {
+ $sb.Append("$a")
+ }
+ $sb.AppendLine("");
+ return $sb.ToString()
+ }
+ # use Write-Output to emit these into the pipeline
+ [array]GetTests() {
+ return $this.Assembly.collection.test
+ }
+ }
+
+ class testAssembly {
+ # attributes
+ [string]$name # path to pester file
+ [string]${config-file}
+ [string]${test-framework} # Pester
+ [string]$environment
+ [string]${run-date}
+ [string]${run-time}
+ [decimal]$time
+ [int]$total
+ [int]$passed
+ [int]$failed
+ [int]$skipped
+ [int]$errors
+ testAssembly ( ) {
+ $this."config-file" = "no config"
+ $this."test-framework" = "Pester"
+ $this.environment = $script:environment
+ $this."run-date" = $script:rundate
+ $this."run-time" = $script:runtime
+ $this.collection = [System.Collections.Generic.List[collection]]::new()
+ }
+ # child elements
+ [error[]]$error
+ [System.Collections.Generic.List[collection]]$collection
+ [string]ToString() {
+ $sb = [System.Text.StringBuilder]::new()
+ $sb.AppendFormat(' ")
+ if ( $this.error ) {
+ $sb.AppendLine(" ")
+ foreach ( $e in $this.error ) {
+ $sb.AppendLine($e.ToString())
+ }
+ $sb.AppendLine(" ")
+ } else {
+ $sb.AppendLine(" ")
+ }
+ foreach ( $col in $this.collection ) {
+ $sb.AppendLine($col.ToString())
+ }
+ $sb.AppendLine(" ")
+ return $sb.ToString()
+ }
+ }
+
+ class collection {
+ # attributes
+ [string]$name
+ [decimal]$time
+ [int]$total
+ [int]$passed
+ [int]$failed
+ [int]$skipped
+ # child element
+ [System.Collections.Generic.List[test]]$test
+ # constructor
+ collection () {
+ $this.test = [System.Collections.Generic.List[test]]::new()
+ }
+ [string]ToString() {
+ $sb = [Text.StringBuilder]::new()
+ if ( $this.test.count -eq 0 ) {
+ $sb.AppendLine(" ")
+ } else {
+ $sb.AppendFormat(' ' + "`n",
+ $this.total, $this.passed, $this.failed, $this.skipped, [security.securityelement]::escape($this.name), $this.time)
+ foreach ( $t in $this.test ) {
+ $sb.AppendLine(" " + $t.ToString());
+ }
+ $sb.Append(" ")
+ }
+ return $sb.ToString()
+ }
+ }
+
+ class errors {
+ [error[]]$error
+ }
+ class error {
+ # attributes
+ [string]$type
+ [string]$name
+ # child elements
+ [failure]$failure
+ [string]ToString() {
+ $sb = [system.text.stringbuilder]::new()
+ $sb.AppendLine('' -f $this.type, [security.securityelement]::escape($this.Name))
+ $sb.AppendLine($this.failure -as [string])
+ $sb.AppendLine("")
+ return $sb.ToString()
+ }
+ }
+
+ class cdata {
+ [string]$text
+ cdata ( [string]$s ) { $this.text = $s }
+ [string]ToString() {
+ return ''
+ }
+ }
+
+ class failure {
+ [string]${exception-type}
+ [cdata]$message
+ [cdata]${stack-trace}
+ failure ( [string]$message, [string]$stack ) {
+ $this."exception-type" = "Pester"
+ $this.Message = [cdata]::new($message)
+ $this."stack-trace" = [cdata]::new($stack)
+ }
+ [string]ToString() {
+ $sb = [text.stringbuilder]::new()
+ $sb.AppendLine(" ")
+ $sb.AppendLine(" " + ($this.message -as [string]) + "")
+ $sb.AppendLine(" " + ($this."stack-trace" -as [string]) + "")
+ $sb.Append(" ")
+ return $sb.ToString()
+ }
+ }
+
+ enum resultenum {
+ Pass
+ Fail
+ Skip
+ }
+
+ class trait {
+ # attributes
+ [string]$name
+ [string]$value
+ }
+ class traits {
+ [trait[]]$trait
+ }
+ class test {
+ # attributes
+ [string]$name
+ [string]$type
+ [string]$method
+ [decimal]$time
+ [resultenum]$result
+ # child elements
+ [trait[]]$traits
+ [failure]$failure
+ [cdata]$reason # skip reason
+ [string]ToString() {
+ $sb = [text.stringbuilder]::new()
+ $sb.appendformat(' ")
+ $sb.AppendLine($this.failure -as [string])
+ $sb.append(' ')
+ } else {
+ $sb.Append("/>")
+ }
+ return $sb.ToString()
+ }
+ }
+
+ function convert-pesterlog ( [xml]$x, $logpath, [switch]$includeEmpty ) {
+ <#$resultMap = @{
+ Success = "Pass"
+ Ignored = "Skip"
+ Failure = "Fail"
+ }#>
+
+ $resultMap = @{
+ Success = "Pass"
+ Ignored = "Skip"
+ Failure = "Fail"
+ Inconclusive = "Skip"
+ }
+
+ $configfile = $logpath
+ $runtime = $x."test-results".time
+ $environment = $x."test-results".environment.platform + "-" + $x."test-results".environment."os-version"
+ $rundate = $x."test-results".date
+ $suites = $x."test-results"."test-suite".results."test-suite"
+ $assemblies = [assemblies]::new()
+ foreach ( $suite in $suites ) {
+ $tCases = $suite.SelectNodes(".//test-case")
+ # only create an assembly group if we have tests
+ if ( $tCases.count -eq 0 -and ! $includeEmpty ) { continue }
+ $tGroup = $tCases | Group-Object result
+ $total = $tCases.Count
+ $asm = [testassembly]::new()
+ $asm.environment = $environment
+ $asm."run-date" = $rundate
+ $asm."run-time" = $runtime
+ $asm.Name = $suite.name
+ $asm."config-file" = $configfile
+ $asm.time = $suite.time
+ $asm.total = $suite.SelectNodes(".//test-case").Count
+ $asm.Passed = $tGroup|? {$_.Name -eq "Success"} | % {$_.Count}
+ $asm.Failed = $tGroup|? {$_.Name -eq "Failure"} | % {$_.Count}
+ $asm.Skipped = $tGroup|? { $_.Name -eq "Ignored" } | % {$_.Count}
+ $asm.Skipped += $tGroup|? { $_.Name -eq "Inconclusive" } | % {$_.Count}
+ $c = [collection]::new()
+ $c.passed = $asm.Passed
+ $c.failed = $asm.failed
+ $c.skipped = $asm.skipped
+ $c.total = $asm.total
+ $c.time = $asm.time
+ $c.name = $asm.name
+ foreach ( $tc in $suite.SelectNodes(".//test-case")) {
+ if ( $tc.result -match "Success|Ignored|Failure" ) {
+ $t = [test]::new()
+ $t.name = $tc.Name
+ $t.time = $tc.time
+ $t.method = $tc.description # the pester actually puts the name of the "it" as description
+ $t.type = $suite.results."test-suite".description | Select-Object -First 1
+ $t.result = $resultMap[$tc.result]
+ if ( $tc.failure ) {
+ $t.failure = [failure]::new($tc.failure.message, $tc.failure."stack-trace")
+ }
+ $null = $c.test.Add($t)
+ }
+ }
+ $null = $asm.collection.add($c)
+ $assemblies.assembly.Add($asm)
+ }
+ $assemblies
+ }
+
+ # convert it to our object model
+ # a simple conversion
+ function convert-xunitlog {
+ param ( $x, $logpath )
+ $asms = [assemblies]::new()
+ $asms.timestamp = $x.assemblies.timestamp
+ foreach ( $assembly in $x.assemblies.assembly ) {
+ $asm = [testAssembly]::new()
+ $asm.environment = $assembly.environment
+ $asm."test-framework" = $assembly."test-framework"
+ $asm."run-date" = $assembly."run-date"
+ $asm."run-time" = $assembly."run-time"
+ $asm.total = $assembly.total
+ $asm.passed = $assembly.passed
+ $asm.failed = $assembly.failed
+ $asm.skipped = $assembly.skipped
+ $asm.time = $assembly.time
+ $asm.name = $assembly.name
+ foreach ( $coll in $assembly.collection ) {
+ $c = [collection]::new()
+ $c.name = $coll.name
+ $c.total = $coll.total
+ $c.passed = $coll.passed
+ $c.failed = $coll.failed
+ $c.skipped = $coll.skipped
+ $c.time = $coll.time
+ foreach ( $t in $coll.test ) {
+ $test = [test]::new()
+ $test.name = $t.name
+ $test.type = $t.type
+ $test.method = $t.method
+ $test.time = $t.time
+ $test.result = $t.result
+ $c.test.Add($test)
+ }
+ $null = $asm.collection.add($c)
+ }
+ $null = $asms.assembly.add($asm)
+ }
+ $asms
+ }
+ $Logs = @()
+ }
+
+ PROCESS {
+ #### MAIN ####
+ foreach ( $log in $Logfile ) {
+ foreach ( $logpath in (resolve-path $log).path ) {
+ write-progress "converting file $logpath"
+ if ( ! $logpath) { throw "Cannot resolve $Logfile" }
+ $x = [xml](get-content -raw -readcount 0 $logpath)
+
+ if ( $x.psobject.properties['test-results'] ) {
+ $Logs += convert-pesterlog $x $logpath -includeempty:$includeempty
+ } elseif ( $x.psobject.properties['assemblies'] ) {
+ $Logs += convert-xunitlog $x $logpath -includeEmpty:$includeEmpty
+ } else {
+ write-error "Cannot determine log type"
+ }
+ }
+ }
+ }
+
+ END {
+ if ( $MultipleLog ) {
+ $Logs
+ } else {
+ $combinedLog = $Logs[0]
+ for ( $i = 1; $i -lt $logs.count; $i++ ) {
+ $combinedLog += $Logs[$i]
+ }
+ $combinedLog
+ }
+ }
+}
+
$script:RESX_TEMPLATE = @'