Public/Invoke-DotNetTestAndPublishResults.ps1

function Invoke-DotNetTestAndPublishResults {
    <#
    .SYNOPSIS
        Runs a .NET test assembly and publishes the results to QAOps.
    .DESCRIPTION
        Executes the given test assembly with 'dotnet test' (or the Microsoft.Testing
        Platform runner when -UsesMTP is true), writes a TRX result file into the test
        package content folder and pushes one QAOps test case result per test case.

        Tests that were not executed are published as NotExecuted unless
        -PublishNotExecuted is disabled.
    .PARAMETER PathToTestPackageContent
        Root of the test package content; the TRX file is written underneath it.
    .PARAMETER TestDllPath
        Path to the test assembly to execute.
    .PARAMETER ResultsFileName
        File name to use for the generated TRX result file.
    .PARAMETER UsesMTP
        'true' when the assembly uses the Microsoft.Testing Platform runner.
    .PARAMETER TestFilter
        Optional filter expression passed to the test runner.
    .PARAMETER PublishNotExecuted
        Publish skipped and not-executed test cases as NotExecuted results.
    .EXAMPLE
        Invoke-DotNetTestAndPublishResults -PathToTestPackageContent 'C:\Content' -TestDllPath 'C:\Content\Tests.dll' -ResultsFileName 'results.trx'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$PathToTestPackageContent,

        [Parameter(Mandatory = $true)]
        [string]$TestDllPath,

        [Parameter(Mandatory = $true)]
        [string]$ResultsFileName,

        [Parameter(Mandatory = $false)]
        [string]$UsesMTP = "false",

        [Parameter(Mandatory = $false)]
        [string]$TestFilter,

        [Parameter(Mandatory = $false)]
        [bool]$PublishNotExecuted = $true
    )

    $usesMtpBool = $false
    if (-not [string]::IsNullOrWhiteSpace($UsesMTP)) {
        $usesMtpBool = $UsesMTP.Trim().ToLowerInvariant() -eq "true"
    }

    if (-not (Test-Path -Path $TestDllPath)) {
        throw "Test assembly not found: $TestDllPath"
    }

    $resultsPath = Join-Path $PathToTestPackageContent $ResultsFileName
    $isExe = [System.IO.Path]::GetExtension($TestDllPath).Equals(".exe", [System.StringComparison]::OrdinalIgnoreCase)

    if (Test-Path -Path $resultsPath) {
        Remove-Item -Path $resultsPath -Force
    }

    try {
        $executionStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
        if ($isExe) {
            $trxFileName = $ResultsFileName
            $exeDirectory = Split-Path -Path $TestDllPath -Parent
            $expectedTrxPath = Join-Path $exeDirectory ("TestResults\" + $trxFileName)
            $arguments = @('--report-trx', '--report-trx-filename', $trxFileName)
            if (-not [string]::IsNullOrWhiteSpace($TestFilter)) {
                $arguments = @('--filter', $TestFilter) + $arguments
            }

            Write-Host "Executing test executable with TRX output: `"$TestDllPath`"" -ForegroundColor Cyan
            if (-not [string]::IsNullOrWhiteSpace($TestFilter)) {
                Write-Host "Applying test filter: $TestFilter" -ForegroundColor Cyan
            }

            & $TestDllPath @arguments

            if ($LASTEXITCODE -ne 0) {
                Write-Warning "Test executable returned exit code $LASTEXITCODE for $TestDllPath (will be reported from TRX)."
            }

            if (-not (Test-Path -Path $expectedTrxPath)) {
                throw "Expected TRX file was not created at: $expectedTrxPath"
            }

            Copy-Item -Path $expectedTrxPath -Destination $resultsPath -Force
        }
        elseif ($usesMtpBool) {
            Write-Host "Executing: dotnet test --test-modules `"$TestDllPath`"" -ForegroundColor Cyan

            $arguments = @('test', '--test-modules', $TestDllPath)
            if (-not [string]::IsNullOrWhiteSpace($TestFilter)) {
                $arguments += @('--filter', $TestFilter)
            }

            & dotnet @arguments

            if ($LASTEXITCODE -ne 0) {
                throw "dotnet test --test-modules failed with exit code $LASTEXITCODE for expression/path: $TestDllPath"
            }

            return
        }
        else {
            Write-Host "Executing: dotnet test `"$TestDllPath`"" -ForegroundColor Cyan
            $arguments = @('test', $TestDllPath, '--logger', "trx;LogFileName=$resultsPath")
            if (-not [string]::IsNullOrWhiteSpace($TestFilter)) {
                $arguments += @('--filter', $TestFilter)
            }

            & dotnet @arguments

            if ($LASTEXITCODE -ne 0) {
                Write-Warning "dotnet test returned exit code $LASTEXITCODE for $TestDllPath (will be reported from TRX)."
            }
        }

        $executionStopwatch.Stop()
        Write-Host "Test execution completed in $($executionStopwatch.Elapsed)." -ForegroundColor Cyan

        if (-not (Test-Path -Path $resultsPath)) {
            throw "Expected TRX results file was not created: $resultsPath"
        }

        [xml]$trx = Get-Content -Path $resultsPath -Raw

        $ns = New-Object System.Xml.XmlNamespaceManager($trx.NameTable)
        $ns.AddNamespace('t', 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010')

        $unitResults = $trx.SelectNodes('//t:UnitTestResult', $ns)
        if (-not $unitResults) {
            throw "No UnitTestResult nodes found in TRX: $resultsPath"
        }

        Write-Host "Publishing $($unitResults.Count) TRX test result(s) to QAOps." -ForegroundColor Cyan
        $publishedCount = 0
        $skippedNotExecutedCount = 0
        $publishStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
        foreach ($r in $unitResults) {
            $testName = $r.GetAttribute('testName')
            $outcome = $r.GetAttribute('outcome')

            if ([string]::IsNullOrWhiteSpace($testName)) {
                $testName = $r.GetAttribute('testId')
            }

            $duration = [TimeSpan]::Zero
            $durationAttribute = $r.GetAttribute('duration')
            if (-not [string]::IsNullOrWhiteSpace($durationAttribute)) {
                $parsed = [TimeSpan]::Zero
                if ([TimeSpan]::TryParse($durationAttribute, [ref]$parsed)) {
                    $duration = $parsed
                }
            }

            if ($outcome -eq 'Passed') {
                try {
                    Push-TestCaseResult -Outcome 'OK' -Name $testName -Duration $duration -Message "Test passed." -TestAspect Assertion
                    $publishedCount++
                }
                catch {
                    Write-Host "Skipped Push for OK on $testName"
                }

                continue
            }

            if (($outcome -eq 'Failed') -or ($outcome -eq 'Error') -or ($outcome -eq 'Timeout') -or ($outcome -eq 'Aborted')) {
                $messageNode = $r.SelectSingleNode('t:Output/t:ErrorInfo/t:Message', $ns)
                $stackNode = $r.SelectSingleNode('t:Output/t:ErrorInfo/t:StackTrace', $ns)

                if ($messageNode -and -not [string]::IsNullOrWhiteSpace($messageNode.InnerText)) {
                    $msg = $messageNode.InnerText.Trim()
                }
                else {
                    $msg = "Test failed."
                }

                if ($stackNode -and -not [string]::IsNullOrWhiteSpace($stackNode.InnerText)) {
                    $msg = $msg + "`n" + $stackNode.InnerText.Trim()
                }

                if (Get-Command -Name Limit-String -ErrorAction SilentlyContinue) {
                    $msg = Limit-String -stringToLimit $msg -maxCharacters 2000
                }

                try {
                    Push-TestCaseResult -Outcome 'Fail' -Name $testName -Duration $duration -Message $msg -TestAspect Assertion
                    $publishedCount++
                }
                catch {
                    Write-Host "Skipped Push for Fail on $testName"
                }

                continue
            }

            if ($outcome -eq 'NotExecuted') {
                if (-not $PublishNotExecuted) {
                    $skippedNotExecutedCount++
                    continue
                }

                $messageNode = $r.SelectSingleNode('t:Output/t:ErrorInfo/t:Message', $ns)

                if ($messageNode -and -not [string]::IsNullOrWhiteSpace($messageNode.InnerText)) {
                    $msg = $messageNode.InnerText.Trim()
                }
                else {
                    $msg = "Test was not executed."
                }

                if (Get-Command -Name Limit-String -ErrorAction SilentlyContinue) {
                    $msg = Limit-String -stringToLimit $msg -maxCharacters 2000
                }

                try {
                    Push-TestCaseResult -Outcome 'NotExecuted' -Name $testName -Duration $duration -Message $msg -TestAspect Assertion
                    $publishedCount++
                }
                catch {
                    Write-Host "Skipped Push for NotExecuted on $testName"
                }

                continue
            }

            try {
                Push-TestCaseResult -Outcome 'Fail' -Name $testName -Duration $duration -Message "Unhandled test outcome '$outcome'." -TestAspect Assertion
                $publishedCount++
            }
            catch {
                Write-Host "Skipped Push for Fail on $testName"
            }
        }

        $publishStopwatch.Stop()
        Write-Host "Published $publishedCount QAOps assertion result(s) in $($publishStopwatch.Elapsed). Skipped NotExecuted result(s): $skippedNotExecutedCount." -ForegroundColor Cyan
    }
    finally {
        if ((-not $usesMtpBool) -and (Test-Path -Path $resultsPath)) {
            try {
                Remove-Item -Path $resultsPath -Force
            }
            catch {
                Write-Warning "Failed to cleanup test output file: $resultsPath. $($_.Exception.Message)"
            }
        }
    }
}