Public/Invoke-DotNetTestAndPublishResults.ps1

function ConvertTo-QAOpsRedactedText {
    param([Parameter()][AllowNull()][string]$Text)
    if ($null -eq $Text) { return '' }
    $redacted = [string]$Text
    foreach ($secret in @($env:QAOPS_PUBLICATION_CONTEXT)) {
        if (-not [string]::IsNullOrWhiteSpace($secret)) { $redacted = $redacted.Replace($secret, '[REDACTED-QAOPS-PUBLICATION-CONTEXT]') }
    }
    $redacted = [regex]::Replace($redacted, '(?i)(QAOPS_PUBLICATION_CONTEXT|X-QAOps-Publication-Context)\s*[:=]\s*\S+', '$1=[REDACTED-QAOPS-PUBLICATION-CONTEXT]')
    return $redacted
}

function Invoke-QAOpsRedactedDotNet {
    param([string[]]$Arguments)
    $oldEap = $ErrorActionPreference
    $ErrorActionPreference = 'Continue'
    $tail = New-Object System.Collections.Queue
    try {
        & dotnet @Arguments 2>&1 | ForEach-Object {
            if ($_ -is [System.Management.Automation.ErrorRecord]) { $line = $_.ToString() } else { $line = [string]$_ }
            $text = ConvertTo-QAOpsRedactedText -Text $line
            if ($tail.Count -ge 200) { [void]$tail.Dequeue() }
            [void]$tail.Enqueue($text)
            if (-not [string]::IsNullOrWhiteSpace($text)) { Write-Host $text }
        }
        return $LASTEXITCODE
    }
    finally { $ErrorActionPreference = $oldEap }
}

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 Microsoft.Testing Platform,
        requests TRX output, reads the generated results, enriches rows from the optional
        maintainer sidecar, and pushes one QAOps test case result per TRX result. New Q
        cmdlet parameters/cmdlets are used only when present, preserving old modules.
    .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
    )

    function ConvertTo-QAOpsOutcomeMessage {
        param([object]$Result)
        if ($Result.Outcome -eq 'Passed') { return [pscustomobject]@{ Outcome='OK'; Message='Test passed.' } }
        if ($Result.Outcome -eq 'NotExecuted') {
            $m = if ([string]::IsNullOrWhiteSpace($Result.Message)) { 'Test was not executed.' } else { $Result.Message }
            return [pscustomobject]@{ Outcome='NotExecuted'; Message=(Limit-String -stringToLimit $m -maxCharacters 2000) }
        }
        $msg = if ([string]::IsNullOrWhiteSpace($Result.Message)) { 'Test failed.' } else { $Result.Message }
        [pscustomobject]@{ Outcome='Fail'; Message=(Limit-String -stringToLimit $msg -maxCharacters 2000) }
    }

    function Invoke-QAOpsCapabilityCommand {
        param([string]$Name,[hashtable]$Parameters)
        $cmd = Get-Command -Name $Name -ErrorAction SilentlyContinue
        if ($null -eq $cmd) { return [pscustomobject]@{ Supported=$false; Accepted=$false; StatusCode=0; ErrorCode='cmdletMissing'; Message="$Name is not available." } }
        try { & $Name @Parameters } catch { [pscustomobject]@{ Supported=$true; Accepted=$false; StatusCode=0; ErrorCode='terminatingError'; Message=$_.Exception.Message } }
    }

    $usesMtpBool = $false
    if (-not [string]::IsNullOrWhiteSpace($UsesMTP)) { $usesMtpBool = $UsesMTP.Trim().ToLowerInvariant() -eq 'true' }
    if (-not (Test-Path -LiteralPath $TestDllPath -PathType Leaf)) { throw "Test assembly not found: $TestDllPath" }

    $contentPath = (Resolve-Path -LiteralPath $PathToTestPackageContent -ErrorAction Stop).ProviderPath
    $resultsPath = Join-Path $contentPath $ResultsFileName
    $isExe = [System.IO.Path]::GetExtension($TestDllPath).Equals('.exe', [System.StringComparison]::OrdinalIgnoreCase)
    $assemblyName = [System.IO.Path]::GetFileName($TestDllPath)
    $attemptId = New-QAOpsUlid
    $publisherErrors = New-Object System.Collections.ArrayList
    $acceptedCount = 0
    $manifestSupported = $true
    $finalizationSupported = $true
    $legacyWarningWritten = $false

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

    try {
        $executionStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
        if ($isExe) {
            $trxFileName = $ResultsFileName
            $exeDirectory = Split-Path -Path $TestDllPath -Parent
            $expectedTrxPath = Join-Path (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
            & $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 -LiteralPath $expectedTrxPath -PathType Leaf)) { Write-Warning "Expected TRX file was not created at: $expectedTrxPath" } else { Copy-Item -LiteralPath $expectedTrxPath -Destination $resultsPath -Force }
        }
        elseif ($usesMtpBool) {
            Write-Host "Executing: dotnet test --test-modules `"$TestDllPath`"" -ForegroundColor Cyan
            $mtpResultsDirectory = Join-Path $contentPath ('.qaops-mtp-results-' + ([guid]::NewGuid().ToString('N')))
            New-Item -Path $mtpResultsDirectory -ItemType Directory -Force | Out-Null
            $arguments = @('test','--test-modules',$TestDllPath,'--report-trx','--report-trx-filename',$ResultsFileName,'--results-directory',$mtpResultsDirectory)
            if (-not [string]::IsNullOrWhiteSpace($TestFilter)) { $arguments += @('--filter',$TestFilter) }
            $dotnetExitCode = Invoke-QAOpsRedactedDotNet -Arguments $arguments
            if ($dotnetExitCode -ne 0) { Write-Warning "dotnet test --test-modules returned exit code $dotnetExitCode for $TestDllPath (will be reported from TRX)." }
            $candidate = Join-Path $mtpResultsDirectory $ResultsFileName
            if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { $candidate = @(Get-ChildItem -LiteralPath $mtpResultsDirectory -Filter $ResultsFileName -File -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1).FullName }
            if ($candidate -and (Test-Path -LiteralPath $candidate -PathType Leaf)) { Copy-Item -LiteralPath $candidate -Destination $resultsPath -Force }
            else { Write-Warning 'MTP did not produce the requested TRX file. Install Microsoft.Testing.Extensions.TrxReport and ensure --report-trx is supported. No finalization will be sent for this package.'; 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) }
            $dotnetExitCode = Invoke-QAOpsRedactedDotNet -Arguments $arguments
            if ($dotnetExitCode -ne 0) { Write-Warning "dotnet test returned exit code $dotnetExitCode for $TestDllPath (will be reported from TRX)." }
        }
        $executionStopwatch.Stop()
        Write-Host "Test execution completed in $($executionStopwatch.Elapsed)." -ForegroundColor Cyan
        if (-not (Test-Path -LiteralPath $resultsPath -PathType Leaf)) { throw "Expected TRX results file was not created: $resultsPath" }

        $sidecar = Import-QAOpsMaintainerSidecar -ContentPath $contentPath
        $rows = @(Get-QAOpsTrxResult -ResultsPath $resultsPath -AssemblyName $assemblyName)
        if ($rows.Count -eq 0) { throw "No UnitTestResult nodes found in TRX: $resultsPath" }
        $enriched = New-Object System.Collections.ArrayList
        foreach ($row in $rows) {
            $lookup = Resolve-QAOpsRuntimeMaintainers -Entries $sidecar.Entries -Assembly $row.Assembly -FullyQualifiedName $row.FullyQualifiedName -DataCaseId $row.DataCaseId -Target 'default'
            foreach ($diag in @($lookup.Diagnostics)) { if ($diag.code -eq 'maintainer-conflict') { Write-Warning "Maintainer conflict for $($diag.key); publishing without maintainers." } }
            if (@($lookup.Diagnostics | Where-Object { $_.code -eq 'identity-invalid' }).Count -gt 0) {
                [void]$publisherErrors.Add([pscustomobject]@{ testInvocationId=$null; code='identity-invalid'; message='The test identity could not be represented as a QAOps canonical key.' })
            }
            [void]$enriched.Add([pscustomobject]@{ Result=$row; Lookup=$lookup; ProducerEventId=New-QAOpsUlid; ExecutionProducerEventId=New-QAOpsUlid })
        }

        if ($manifestSupported) {
            $invocations = @($enriched | Where-Object { -not [string]::IsNullOrEmpty($_.Lookup.TestInvocationId) } | ForEach-Object { [pscustomobject]@{ testInvocationId=$_.Lookup.TestInvocationId; displayName=$_.Result.DisplayName; maintainers=$_.Lookup.Maintainers; diagnostics=(@($_.Lookup.Diagnostics) + @($_.Result.Diagnostics)); comparable=$_.Result.Comparable } })
            $manifestParams = @{ CountSemanticsVersion='qaops-counts-v1'; ExpectedTests=$invocations.Count; DiscoveredTests=$invocations.Count; TestInvocations=$invocations }
            $manifestCommand = Get-Command -Name 'Push-TestRunManifest' -ErrorAction SilentlyContinue
            if ($manifestCommand -and $manifestCommand.Parameters.ContainsKey('SelectionKey')) {
                $manifestParams['SelectionKey'] = [ordered]@{
                    assembly = $assemblyName
                    runner = if ($UsesMTP -eq 'true') { 'MTP' } else { 'VSTest' }
                    filter = $TestFilter
                    target = 'default'
                    publishNotExecuted = $PublishNotExecuted
                } | ConvertTo-Json -Compress
            }
            $manifestResult = Invoke-QAOpsCapabilityCommand -Name 'Push-TestRunManifest' -Parameters $manifestParams
            if ($manifestResult -and $manifestResult.PSObject.Properties.Name -contains 'Supported' -and -not $manifestResult.Supported) { $manifestSupported = $false; Write-Warning 'Push-TestRunManifest is unavailable; continuing with legacy result publishing.' }
            elseif ($manifestResult -and $manifestResult.PSObject.Properties.Name -contains 'Accepted' -and -not $manifestResult.Accepted) { [void]$publisherErrors.Add([pscustomobject]@{ testInvocationId=$null; code='PushTestRunManifestNotAccepted'; message=(Limit-String -stringToLimit ([string]$manifestResult.Message) -maxCharacters 1024) }) }
        }

        Write-Host "Publishing $($rows.Count) TRX test result(s) to QAOps." -ForegroundColor Cyan
        $caseCommand = Get-Command -Name Push-TestCaseResult -ErrorAction SilentlyContinue
        if ($null -eq $caseCommand) { Write-Warning 'Push-TestCaseResult is not available; results cannot be published.' }
        foreach ($item in $enriched) {
            $r = $item.Result
            if ($r.Outcome -eq 'NotExecuted' -and -not $PublishNotExecuted) { continue }
            $om = ConvertTo-QAOpsOutcomeMessage -Result $r
            $aspects = @('Assertion')
            if ($caseCommand -and $caseCommand.Parameters.ContainsKey('TestInvocationId') -and
                $caseCommand.Parameters.ContainsKey('ProducerEventId') -and -not [string]::IsNullOrEmpty($item.Lookup.TestInvocationId)) {
                $aspects += 'Execution'
            }
            foreach ($aspect in $aspects) {
                $parameters = @{ Outcome=$om.Outcome; Name=$r.DisplayName; Duration=$r.Duration; Message=$om.Message; TestAspect=$aspect }
                if ($caseCommand) {
                        $supportedParams = $caseCommand.Parameters
                        if (-not $legacyWarningWritten -and (-not $supportedParams.ContainsKey('ProducerEventId') -or -not $supportedParams.ContainsKey('TestInvocationId') -or -not $supportedParams.ContainsKey('AttemptId') -or -not $supportedParams.ContainsKey('Maintainers'))) {
                            Write-Warning 'Push-TestCaseResult does not support QAOps transport metadata; continuing with legacy result publishing.'
                            $legacyWarningWritten = $true
                        }
                        if ($supportedParams.ContainsKey('ProducerEventId')) {
                            $parameters['ProducerEventId'] = if ($aspect -eq 'Execution') { $item.ExecutionProducerEventId } else { $item.ProducerEventId }
                        }
                        if ($supportedParams.ContainsKey('TestInvocationId') -and -not [string]::IsNullOrEmpty($item.Lookup.TestInvocationId)) { $parameters['TestInvocationId'] = $item.Lookup.TestInvocationId }
                        if ($supportedParams.ContainsKey('AttemptId')) { $parameters['AttemptId'] = $attemptId }
                        $maintainerValidation = Test-QAOpsMaintainerEnvelope -Maintainers $item.Lookup.Maintainers
                        if ($supportedParams.ContainsKey('Maintainers') -and $maintainerValidation.Valid) { $parameters['Maintainers'] = $maintainerValidation.Json }
                        elseif ($item.Lookup.Maintainers -and -not $maintainerValidation.Valid) { Write-Warning "Dropping invalid maintainer envelope for $($item.Lookup.TestInvocationId): $($maintainerValidation.Code)" }
                        try {
                            $pushResult = Push-TestCaseResult @parameters
                            $accepted = $true
                            if ($pushResult -and $pushResult.PSObject.Properties.Name -contains 'Accepted') { $accepted = [bool]$pushResult.Accepted }
                            if ($accepted) { $acceptedCount++ } else { [void]$publisherErrors.Add([pscustomobject]@{ testInvocationId=$item.Lookup.TestInvocationId; code='PushTestCaseResultNotAccepted'; message=(Limit-String -stringToLimit ([string]$pushResult.Message) -maxCharacters 1024) }); Write-Warning "Push-TestCaseResult was not accepted for $($item.Lookup.TestInvocationId)." }
                        }
                        catch {
                            if ($parameters.ContainsKey('Maintainers') -and $_.Exception.Message -match 'maintain') {
                                Write-Warning "Push-TestCaseResult rejected maintainer metadata for $($item.Lookup.TestInvocationId); retrying without maintainers."
                                $parameters.Remove('Maintainers')
                                try {
                                    $pushResult = Push-TestCaseResult @parameters
                                    $retryAccepted = $true
                                    if ($pushResult -and $pushResult.PSObject.Properties.Name -contains 'Accepted') { $retryAccepted = [bool]$pushResult.Accepted }
                                    if ($retryAccepted) { $acceptedCount++ }
                                    else { [void]$publisherErrors.Add([pscustomobject]@{ testInvocationId=$item.Lookup.TestInvocationId; code='PushTestCaseResultNotAccepted'; message=(Limit-String -stringToLimit ([string]$pushResult.Message) -maxCharacters 1024) }); Write-Warning "Push-TestCaseResult retry was not accepted for $($item.Lookup.TestInvocationId)." }
                                }
                                catch { [void]$publisherErrors.Add([pscustomobject]@{ testInvocationId=$item.Lookup.TestInvocationId; code='PushTestCaseResultFailed'; message=(Limit-String -stringToLimit $_.Exception.Message -maxCharacters 1024) }); Write-Warning "Push-TestCaseResult failed for $($item.Lookup.TestInvocationId): $($_.Exception.Message)" }
                            } else { [void]$publisherErrors.Add([pscustomobject]@{ testInvocationId=$item.Lookup.TestInvocationId; code='PushTestCaseResultFailed'; message=(Limit-String -stringToLimit $_.Exception.Message -maxCharacters 1024) }); Write-Warning "Push-TestCaseResult failed for $($item.Lookup.TestInvocationId): $($_.Exception.Message)" }
                        }
                }
            }
        }

        if ($finalizationSupported) {
            $errorsForFinalization = @($publisherErrors | Select-Object -First 100)
            $finalResult = Invoke-QAOpsCapabilityCommand -Name 'Push-TestRunFinalization' -Parameters @{ PublishedResultCount=$acceptedCount; PublisherErrors=$errorsForFinalization }
            if ($finalResult -and $finalResult.PSObject.Properties.Name -contains 'Supported' -and -not $finalResult.Supported) { $finalizationSupported = $false; Write-Warning 'Push-TestRunFinalization is unavailable; run completeness may remain unverifiable.' }
            elseif ($finalResult -and $finalResult.PSObject.Properties.Name -contains 'Accepted' -and -not $finalResult.Accepted) { Write-Warning "Push-TestRunFinalization was not accepted: $($finalResult.Message)" }
        }
        Write-Host "Published $acceptedCount QAOps result emission(s). Publisher error(s): $($publisherErrors.Count)." -ForegroundColor Cyan
    }
    finally {
        if ($mtpResultsDirectory -and (Test-Path -LiteralPath $mtpResultsDirectory -PathType Container)) { try { Remove-Item -LiteralPath $mtpResultsDirectory -Recurse -Force } catch { Write-Warning "Failed to cleanup MTP output directory: $mtpResultsDirectory. $($_.Exception.Message)" } }
        if (Test-Path -LiteralPath $resultsPath -PathType Leaf) {
            try { Remove-Item -LiteralPath $resultsPath -Force } catch { Write-Warning "Failed to cleanup test output file: $resultsPath. $($_.Exception.Message)" }
        }
    }
}