build/scripts/ConvertTo-NUnitXml.ps1

# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

<#
    .DESCRIPTION
        This script takes the output of PSScriptAnalyzer and converts it into an NUnit XML schema
        file which can be fed into CI test reporting.
 
    .PARAMETER ScriptAnalyzerResult
        The output from Invoke-PSScriptAnalzyer to be written to an NUnit XML file.
 
    .PARAMETER Path
        The path the xml config file should be written to.
 
    .PARAMETER Force
        Overwrite the file at Path if it exists.
 
    .EXAMPLE
        $results = Invoke-ScriptAnalyzer -Settings ./PSScriptAnalyzerSettings.psd1 -Path ./ -Recurse
        .\ConverTo-NUnitXml.ps1 -ScriptAnalyzerResult $results -Path ./PSScriptAnalyzerFailures.xml
#>

[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "", Justification = "This is the preferred way of writing output for Azure DevOps.")]
param(
    [Parameter(Mandatory)]
    [AllowNull()]
    [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]] $ScriptAnalyzerResult,

    [Parameter(Mandatory)]
    [string] $Path,

    [switch] $Force
)

# Convert sucess/failure into the appropriate terms for certain NUnit attributes.
$script:SuccessTerms = @{
    $true = [PSCustomObject]@{
        'result' = 'Success'
        'success' = 'True'}

    $false = [PSCustomObject]@{
        'result' = 'Failure'
        'success' = 'False'}
}

function Resolve-UnverifiedPath
{
<#
    .SYNOPSIS
        A wrapper around Resolve-Path that works for paths that exist as well
        as for paths that don't (Resolve-Path normally throws an exception if
        the path doesn't exist.)
 
    .DESCRIPTION
        A wrapper around Resolve-Path that works for paths that exist as well
        as for paths that don't (Resolve-Path normally throws an exception if
        the path doesn't exist.)
 
    .EXAMPLE
        Resolve-UnverifiedPath -Path 'c:\windows\notepad.exe'
 
        Returns the string 'c:\windows\notepad.exe'.
 
    .EXAMPLE
        Resolve-UnverifiedPath -Path '..\notepad.exe'
 
        Returns the string 'c:\windows\notepad.exe', assuming that it's executed from
        within 'c:\windows\system32' or some other sub-directory.
 
    .EXAMPLE
        Resolve-UnverifiedPath -Path '..\foo.exe'
 
        Returns the string 'c:\windows\foo.exe', assuming that it's executed from
        within 'c:\windows\system32' or some other sub-directory, even though this
        file doesn't exist.
 
    .OUTPUTS
        [string] - The fully resolved path
 
#>

    [CmdletBinding()]
    param(
        [Parameter(
            Position=0,
            ValueFromPipeline)]
        [string] $Path
    )

    process
    {
        $resolvedPath = Resolve-Path -Path $Path -ErrorVariable resolvePathError -ErrorAction SilentlyContinue

        if ($null -eq $resolvedPath)
        {
            Write-Output -InputObject ($resolvePathError[0].TargetObject)
        }
        else
        {
            Write-Output -InputObject ($resolvedPath.ProviderPath)
        }
    }
}

function New-NUnitXml
{
<#
    .SYNOPSIS
        Creates a new, empty NUnit Xml file.
 
    .OUTPUTS
        XmlDocument
 
    .NOTES
        It's expected that the "total" and "failures" attributes on test-results will be updated
        by the caller after this object has been returned.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "", Justification="This does not change any system state.")]
    param()

    $date = Get-Date
    $dateString = $date.ToString("yyyy-MM-dd")
    $timeString = $date.ToString("HH:mm:ss")

    $xml = [xml]([String]::Format('<?xml version="1.0" encoding="utf-8"?>
    <test-results language="en-us"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="nunit_schema_2.5.xsd"
        name="PSScriptAnalyzer"
        total="0" errors="0" failures="0" not-run="0" inconclusive="0" ignored="0" skipped="0" invalid="0"
        date="{0}" time="{1}"/>'
,
        $dateString, $timeString))

    return $xml
}

function Add-Environment
{
<#
    .SYNOPSIS
        Adds the environment node to the NUnit Xml document.
 
    .PARAMETER Parent
        The parent element that the element is being added to.
#>

    param(
        [Parameter(Mandatory)]
        [System.Xml.XmlElement] $Parent
    )

    try
    {
        $environment = $Parent.OwnerDocument.CreateElement('environment', $Parent.OwnerDocument.NamespaceURI)
        $null = $Parent.AppendChild($environment)
        $environment.SetAttribute('user', $env:USERNAME)
        $environment.SetAttribute('machine-name', $env:COMPUTERNAME)
        $environment.SetAttribute('cwd', (Get-Location))
        $environment.SetAttribute('user-domain', $env:USERDOMAIN)
        $environment.SetAttribute('nunit-version', '2.5.8.0')
        $environment.SetAttribute('platform', ([System.Environment]::OSVersion.Platform))
        $environment.SetAttribute('os-version', ([System.Environment]::OSVersion.Version.ToString()))
        $environment.SetAttribute('clr-version', $PSVersionTable.CLRVersion)

        return $environment
    }
    catch
    {
        throw
    }
}

function Add-CultureInfo
{
<#
    .SYNOPSIS
        Adds the culture-info node to the NUnit Xml document.
 
    .PARAMETER Parent
        The parent element that the element is being added to.
#>

    param(
        [Parameter(Mandatory)]
        [System.Xml.XmlElement] $Parent
    )

    try
    {
        $cultureInfo = $Parent.OwnerDocument.CreateElement('culture-info', $Parent.OwnerDocument.NamespaceURI)
        $null = $Parent.AppendChild($cultureInfo)
        $cultureInfo.SetAttribute('current-culture', ((Get-Culture).Name))
        $cultureInfo.SetAttribute('current-uiculture', ((Get-UICulture).Name))

        return $cultureInfo
    }
    catch
    {
        throw
    }
}

function Add-TestSuite
{
<#
    .SYNOPSIS
        Adds a test-suite node to the NUnit Xml document.
 
    .PARAMETER Parent
        The parent element that the element is being added to.
#>

    param(
        [Parameter(Mandatory)]
        [System.Xml.XmlElement] $Parent,

        [Parameter(Mandatory)]
        [string] $Type,

        [Parameter(Mandatory)]
        [string] $Name,

        [string] $Description,

        [switch] $Succeeded
    )

    try
    {
        $testSuite = $Parent.OwnerDocument.CreateElement('test-suite', $Parent.OwnerDocument.NamespaceURI)
        $null = $Parent.AppendChild($testSuite)
        $testSuite.SetAttribute('type', $Type)
        $testSuite.SetAttribute('name', $Name)
        if ($PSBoundParameters.ContainsKey('Description')) { $testSuite.SetAttribute('description', $Description) }
        $testSuite.SetAttribute('executed', 'True')
        $testSuite.SetAttribute('time', '0.0')
        $testSuite.SetAttribute('asserts', '0')
        $testSuite.SetAttribute('result', $script:SuccessTerms[$Succeeded.ToBool()].result)
        $testSuite.SetAttribute('success', $script:SuccessTerms[$Succeeded.ToBool()].success)

        return $testSuite
    }
    catch
    {
        throw
    }
}

function Add-Results
{
<#
    .SYNOPSIS
        Adds a results node to the NUnit Xml document.
 
    .PARAMETER Parent
        The parent element that the element is being added to.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "", Justification="This is intended to reflect the actual name of the node being added.")]
    param(
        [Parameter(Mandatory)]
        [System.Xml.XmlElement] $Parent
    )

    try
    {
        $results = $Parent.OwnerDocument.CreateElement('results', $Parent.OwnerDocument.NamespaceURI)
        $null = $Parent.AppendChild($results)

        return $results
    }
    catch
    {
        throw
    }
}

function Add-TestCase
{
<#
    .SYNOPSIS
        Adds a test case result to an existing XMLElement results node.
 
    .PARAMETER Parent
        The parent element that the element is being added to.
 
    .PARAMETER Name
        The name of the test case.
 
    .PARAMETER Description
        A descrition of the test case.
 
    .PARAMETER ScriptAnalyzerResult
        The PSScriptAnalyzer result record which explains the specific failure.
 
    .OUTPUTS
        XmlElement. Returns a reference to the newly created test-case element.
 
    .EXAMPLE
        Add-TestCase -Parent $element -Name 'All entries for this rule succeeded' -Description 'All entries for this rule succeeded'
 
        Adds a successful test-case element to the parent element provided.
 
    .EXAMPLE
        Add-TestCase -Parent $element -ScriptAnalyzerResult $result
 
        Adds a failure test-case element to the parent element provided, with the relevant
        information extracted from the PSScriptAnalyzer result object.
#>

    [CmdletBinding(DefaultParameterSetName='Success')]
    param(
        [Parameter(
            Mandatory,
            ParameterSetName='Success')]
        [Parameter(
            Mandatory,
            ParameterSetName='Failure')]
        [System.Xml.XmlElement] $Parent,

        [Parameter(
            Mandatory,
            ParameterSetName='Success')]
        [string] $Name,

        [Parameter(
            Mandatory,
            ParameterSetName='Success')]
        [string] $Description,

        [Parameter(
            Mandatory,
            ParameterSetName='Failure')]
        [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord] $ScriptAnalyzerResult
    )

    try
    {
        $testCase = $Parent.OwnerDocument.CreateElement('test-case', $Parent.OwnerDocument.NamespaceURI)
        $null = $Parent.AppendChild($testCase)

        $succeeded = ($PSCmdlet.ParameterSetName -eq 'Success')
        if (-not $succeeded)
        {
            $Name = "[$($ScriptAnalyzerResult.RuleName)] - $($ScriptAnalyzerResult.ScriptPath):$($ScriptAnalyzerResult.Line), $($ScriptAnalyzerResult.Column)"

            $rules = Get-ScriptAnalyzerRule
            $Description = ($rules | Where-Object { $_.RuleName -eq $ScriptAnalyzerResult.RuleName }).Description
        }

        $testCase.SetAttribute('name', $Name)
        $testCase.SetAttribute('description', $Description)
        $testCase.SetAttribute('time', '0.0')
        $testCase.SetAttribute('executed', 'True')
        $testCase.SetAttribute('asserts', '0')
        $testCase.SetAttribute('result', $script:SuccessTerms[$succeeded].result)
        $testCase.SetAttribute('success', $script:SuccessTerms[$succeeded].success)

        if (-not $succeeded)
        {
            $failure = $Parent.OwnerDocument.CreateElement('failure', $Parent.OwnerDocument.NamespaceURI)
            $null = $testCase.AppendChild($failure)

            $message = $Parent.OwnerDocument.CreateElement('message', $Parent.OwnerDocument.NamespaceURI)
            $null = $failure.AppendChild($message)
            $message.InnerText = $ScriptAnalyzerResult.Message

            $stackTraceElement = $Parent.OwnerDocument.CreateElement('stack-trace', $Parent.OwnerDocument.NamespaceURI)
            $null = $failure.AppendChild($stackTraceElement)

            $generatedStackTrace = @(
                "at line: $($ScriptAnalyzerResult.line) in $($ScriptAnalyzerResult.ScriptPath)",
                " $($ScriptAnalyzerResult.Extent.Text)",
                " Severity: $($ScriptAnalyzerResult.Severity)")
            $stackTraceElement.InnerText = ($generatedStackTrace -join [Environment]::NewLine)
        }

        return $testCase
    }
    catch
    {
        throw
    }

}

function ConvertTo-NUnitXml
{
<#
    .DESCRIPTION
        Takes the output of PSScriptAnalyzer and converts it into an NUnit XML schema
        object.
 
    .PARAMETER ScriptAnalyzerResult
        One or more PSScriptAnalyzer result records to be written to the NUnit XML file.
 
    .OUTPUTS
        XmlDocument
#>

    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]] $ScriptAnalyzerResult
    )

    try
    {
        $hasFailures = ($ScriptAnalyzerResult.Count -gt 0)
        $totalTests = 0
        $totalFailures = 0

        $xml = New-NUnitXml
        $null = Add-Environment -Parent $xml.DocumentElement
        $null = Add-CultureInfo -Parent $xml.DocumentElement
        $mainTestSuite = Add-TestSuite -Parent $xml.DocumentElement -Type 'PowerShell' -Name -'PSScriptAnalyzer' -Succeeded:(-not $hasFailures)
        $mainResults = Add-Results -Parent $mainTestSuite

        $rules = Get-ScriptAnalyzerRule
        foreach ($rule in $rules)
        {
            $failures = $ScriptAnalyzerResult | Where-Object { $_.RuleName -eq $rule }

            $testSuite = Add-TestSuite -Parent $mainResults -Type 'TestFixture' -Name $rule.RuleName -Description $rule.Description -Succeeded:($failures.Count -eq 0)
            $results = Add-Results -Parent $testSuite

            if ($failures.Count -eq 0)
            {
                $name = "All files pass rule [$($rule.RuleName)]"
                $null = Add-TestCase -Parent $results -Name $name -Description $rule.Description
                $totalTests++
            }
            else
            {
                foreach ($failure in $failures)
                {
                    $null = Add-TestCase -Parent $results -ScriptAnalyzerResult $failure
                    $totalTests++
                    $totalFailures++
                }
            }
        }

        # Finally, we need to update a few attributes in the root test-results
        $xml.'test-results'.total = $totalTests.ToString()
        $xml.'test-results'.failures = $totalFailures.ToString()

        # Catch an odd edge case if somehow there was a rule that failed that wasn't in the list
        # of rules returned.
        if ($totalFailures -ne $ScriptAnalyzerResult.Count)
        {
            Write-Error "The total generated number of failures ($totalFailures) does not match the expected number of failures ($($ScriptAnalyzerResult.Count))."
        }

        return $xml
    }
    catch
    {
        throw
    }
}

# Script body

$scriptName = Split-Path -Leaf -Path $PSCommandPath
try
{
    Write-Host "$($scriptName): Trying to create NUnit XML file based off of the provided PSScriptAnalyzer results."

    $Path = Resolve-UnverifiedPath -Path $Path
    if ((Test-Path -Path $Path -PathType Leaf) -and (-not $Force))
    {
        throw "File at [$Path] already exists, but -Force was not specified. Exiting without replacing it."
    }

    $xml = ConvertTo-NUnitXml -ScriptAnalyzerResult $ScriptAnalyzerResult
    $xml.Save($Path)

    Write-Host "$($scriptName): Successfully created the NUnit XML file"
}
catch
{
    Write-Host "$($scriptName): Failed to create the NUnit XML file."
    throw
}