internal/ConvertTo-MtMaesterResult.ps1

<#
.SYNOPSIS
  Converts Pester results to the Maester test results format which includes additional information.
#>


function ConvertTo-MtMaesterResult {
    [CmdletBinding()]
    param(
        # The Pester test results returned from Invoke-Pester -PassThru
        [Parameter(Mandatory = $true)]
        [psobject] $PesterResults,

        # Optional output files information
        [Parameter(Mandatory = $false)]
        [psobject] $OutputFiles,

        # Optional Invoke-Maester command that was run
        [Parameter(Mandatory = $false)]
        [string] $InvokeMaesterCommand,

        # Optional Pester configuration that was used
        [Parameter(Mandatory = $false)]
        [psobject] $PesterConfiguration
    )

    function GetTenantName() {
        if (Test-MtConnection Graph) {
            $org = Invoke-MtGraphRequest -RelativeUri 'organization'
            return $org.DisplayName
        } elseif (Test-MtConnection Teams) {
            $tenant = Get-CsTenant
            return $tenant.DisplayName
        } else {
            return "TenantName (not connected to Graph)"
        }
    }

    function GetTenantId() {
        if (Test-MtConnection Graph) {
            $mgContext = Get-MgContext
            return $mgContext.TenantId
        } elseif (Test-MtConnection Teams) {
            $tenant = Get-CsTenant
            return $tenant.TenantId
        } else {
            return "TenantId (not connected to Graph)"
        }
    }

    function GetAccount() {
        if (Test-MtConnection Graph) {
            $mgContext = Get-MgContext
            return $mgContext.Account
            #} elseif (Test-MtConnection Teams) {
            # $tenant = Get-CsTenant #ToValidate: N/A
            # return $tenant.DisplayName
        } else {
            return "Account (not connected to Graph)"
        }
    }

    function GetTestsSorted() {
        # Show passed and failed tests first by name then show not run tests
        $activeTests = $PesterResults.Tests | Where-Object { $_.Result -eq 'Passed' -or $_.Result -eq 'Failed' } | Sort-Object -Property Name
        $inactiveTests = $PesterResults.Tests | Where-Object { $_.Result -ne 'Passed' -and $_.Result -ne 'Failed' } | Sort-Object -Property Name

        # Convert to array and add, if not when only one object is returned it doesn't create an array with all items.
        return @($activeTests) + @($inactiveTests)
    }

    function GetFormattedDate($date) {
        if (!$IsCoreCLR) {
            # Prevent 5.1 date format to json issue
            return $date.ToString("o")
        } else {
            return $date
        }
    }

    function GetMaesterLatestVersion() {
        if (Get-Command 'Find-Module' -ErrorAction SilentlyContinue) {
            return (Find-Module -Name Maester).Version
        }

        return 'Unknown'
    }

    function GetSystemInfo() {
        $systemInfo = [PSCustomObject]@{
            MachineName    = [System.Environment]::MachineName
            OSDescription  = if ($PSVersionTable.OS) { $PSVersionTable.OS } else { [System.Environment]::OSVersion.VersionString }
            OSPlatform     = if ($IsWindows) { 'Windows' } elseif ($IsMacOS) { 'macOS' } elseif ($IsLinux) { 'Linux' } else { 'Windows' }
            ProcessorCount = [System.Environment]::ProcessorCount
            UserName       = [System.Environment]::UserName
            UserDomain     = [System.Environment]::UserDomainName
        }
        return $systemInfo
    }

    function GetPowerShellInfo() {
        $psInfo = [PSCustomObject]@{
            Version       = $PSVersionTable.PSVersion.ToString()
            Edition       = $PSVersionTable.PSEdition
            Platform      = if ($PSVersionTable.Platform) { $PSVersionTable.Platform } else { 'Win32NT' }
        }
        return $psInfo
    }

    function GetLoadedModules() {
        $modules = Get-Module | ForEach-Object {
            [PSCustomObject]@{
                Name    = $_.Name
                Version = $_.Version.ToString()
            }
        } | Sort-Object -Property Name
        return @($modules)
    }

    function GetMgContextInfo() {
        if (Test-MtConnection Graph) {
            $mgContext = Get-MgContext
            if ($null -ne $mgContext) {
                return [PSCustomObject]@{
                    ClientId             = $mgContext.ClientId
                    TenantId             = $mgContext.TenantId
                    Scopes               = @($mgContext.Scopes)
                    AuthType             = [string]$mgContext.AuthType
                    TokenCredentialType  = [string]$mgContext.TokenCredentialType
                    Account              = $mgContext.Account
                    AppName              = $mgContext.AppName
                    ContextScope         = [string]$mgContext.ContextScope
                    Environment          = $mgContext.Environment
                    ManagedIdentityId    = $mgContext.ManagedIdentityId
                }
            }
        }
        return $null
    }

    function GetLogoAsBase64DataUri($uri) {
        try {
            $response = Invoke-MgGraphRequest -Uri $uri -OutputType HttpResponseMessage
            if ($response.IsSuccessStatusCode) {
                $logoBytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
                if ($null -ne $logoBytes -and $logoBytes.Length -gt 0) {
                    $base64Logo = [System.Convert]::ToBase64String($logoBytes)
                    # Detect content type from response or default to png
                    $contentType = $response.Content.Headers.ContentType.MediaType
                    if ([string]::IsNullOrEmpty($contentType)) { $contentType = "image/png" }
                    return "data:$contentType;base64,$base64Logo"
                }
            }
        } catch {
            Write-Verbose "Could not retrieve logo from $uri : $_"
        }
        return $null
    }

    function GetOrganizationLogos() {
        $logos = [PSCustomObject]@{
            Banner = $null
        }

        if (Test-MtConnection Graph) {
            $mgContext = Get-MgContext
            $orgId = $mgContext.TenantId

            # Try to get the banner logo
            $logos.Banner = GetLogoAsBase64DataUri "https://graph.microsoft.com/v1.0/organization/$orgId/branding/localizations/default/bannerLogo"
        }

        # Return null if no logo is available, otherwise return the logos object
        if ($null -eq $logos.Banner) {
            return $null
        }
        return $logos
    }

    function GetPesterConfigInfo($config) {
        if ($null -eq $config) { return $null }

        # Convert PesterConfiguration to a simple hashtable for JSON serialization
        $configInfo = [PSCustomObject]@{
            Run = [PSCustomObject]@{
                Path                   = @($config.Run.Path.Value)
                ExcludePath            = @($config.Run.ExcludePath.Value)
                ScriptBlock            = @($config.Run.ScriptBlock.Value | ForEach-Object { if ($null -ne $_) { $_.ToString() } })
                Container              = @($config.Run.Container.Value)
                TestExtension          = $config.Run.TestExtension.Value
                Exit                   = $config.Run.Exit.Value
                Throw                  = $config.Run.Throw.Value
                PassThru               = $config.Run.PassThru.Value
                SkipRun                = $config.Run.SkipRun.Value
                SkipRemainingOnFailure = [string]$config.Run.SkipRemainingOnFailure.Value
            }
            Filter = [PSCustomObject]@{
                Tag         = @($config.Filter.Tag.Value)
                ExcludeTag  = @($config.Filter.ExcludeTag.Value)
                Line        = @($config.Filter.Line.Value)
                ExcludeLine = @($config.Filter.ExcludeLine.Value)
                FullName    = @($config.Filter.FullName.Value)
            }
            CodeCoverage = [PSCustomObject]@{
                Enabled               = $config.CodeCoverage.Enabled.Value
                OutputFormat          = $config.CodeCoverage.OutputFormat.Value
                OutputPath            = $config.CodeCoverage.OutputPath.Value
                OutputEncoding        = $config.CodeCoverage.OutputEncoding.Value
                Path                  = @($config.CodeCoverage.Path.Value)
                ExcludeTests          = $config.CodeCoverage.ExcludeTests.Value
                RecursePaths          = $config.CodeCoverage.RecursePaths.Value
                CoveragePercentTarget = $config.CodeCoverage.CoveragePercentTarget.Value
                UseBreakpoints        = $config.CodeCoverage.UseBreakpoints.Value
                SingleHitBreakpoints  = $config.CodeCoverage.SingleHitBreakpoints.Value
            }
            TestResult = [PSCustomObject]@{
                Enabled        = $config.TestResult.Enabled.Value
                OutputFormat   = $config.TestResult.OutputFormat.Value
                OutputPath     = $config.TestResult.OutputPath.Value
                OutputEncoding = $config.TestResult.OutputEncoding.Value
                TestSuiteName  = $config.TestResult.TestSuiteName.Value
            }
            Should = [PSCustomObject]@{
                ErrorAction = [string]$config.Should.ErrorAction.Value
            }
            Debug = [PSCustomObject]@{
                ShowFullErrors         = $config.Debug.ShowFullErrors.Value
                WriteDebugMessages     = $config.Debug.WriteDebugMessages.Value
                WriteDebugMessagesFrom = @($config.Debug.WriteDebugMessagesFrom.Value)
                ShowNavigationMarkers  = $config.Debug.ShowNavigationMarkers.Value
                ReturnRawResultObject  = $config.Debug.ReturnRawResultObject.Value
            }
            Output = [PSCustomObject]@{
                Verbosity           = [string]$config.Output.Verbosity.Value
                StackTraceVerbosity = [string]$config.Output.StackTraceVerbosity.Value
                CIFormat            = [string]$config.Output.CIFormat.Value
                CILogLevel          = [string]$config.Output.CILogLevel.Value
                RenderMode          = [string]$config.Output.RenderMode.Value
            }
        }
        return $configInfo
    }

    #if(Test-MtConnection Graph) { #ToValidate: Issue with -SkipGraphConnect
    # $mgContext = Get-MgContext
    #}

    #$tenantId = $mgContext.TenantId ?? "Tenant ID (not connected to Graph)"
    $tenantId = GetTenantId
    $tenantName = GetTenantName
    #$account = $mgContext.Account ?? "Account (not connected to Graph)"
    $account = GetAccount

    $currentVersion = Get-MtModuleVersion
    $latestVersion = GetMaesterLatestVersion

    $mtTests = @()
    $sortedTests = GetTestsSorted

    $testIndex = 0

    foreach ($test in $sortedTests) {
        $testIndex++

        $name = $test.ExpandedName
        $testCustomName = $__MtSession.TestResultDetail[$test.ExpandedName].TestTitle
        if (![string]::IsNullOrEmpty($testCustomName)) {
            # Use the custom title if it's been provided.
            $name = $testCustomName
        }

        $helpUrl = ''

        $start = $name.IndexOf("See https")
        # Get the Help Url from the message and the ID
        if ($start -gt 0) {
            $helpUrl = $name.Substring($start + 4).Trim() #Strip away the "See https://maester.dev" part
            $name = $name.Substring(0, $start).Trim() #Strip away the "See https://maester.dev" part
        }


        # Find the first : and use the first part as $testId and remaining as $testTitle
        # If no : is found or if there are spaces before the first : display a warning that the test name is not in the correct format
        $titleStart = $name.IndexOf(':')
        $testId = $name # Default to the full test name if no split is found
        $testTitle = $name # Default to the full test name if no split is found

        if ($titleStart -gt 0) {
            $testId = $name.Substring(0, $titleStart).Trim()
            $testTitle = $name.Substring($titleStart + 1).Trim()
        } else {
            Write-Warning "Test name does not contain a ':' character. Please use the format 'TestId: TestTitle' → $name"
        }
        $testResultDetail = $__MtSession.TestResultDetail[$test.ExpandedName]

        # Add the other test metadata to the test result
        $testSetting = Get-MtMaesterConfigTestSetting -TestId $testId
        $severity = $testResultDetail.Severity # Default to the test result severity
        if ($testSetting -and [string]::IsNullOrEmpty($testSetting.Severity) -eq $false) {
            # Overwrite the settings if it is set in the config
            $severity = $testSetting.Severity
        }

        # Setting Result to Error, Overwriting the Skipped state
        if ($testResultDetail.TestSkipped -eq "Error" ) {
            $result = "Error"
        } elseif ((
                $test -and
                $test.ErrorRecord -and
                $test.ErrorRecord.Count -gt 0 -and
                $test.ErrorRecord[0].CategoryInfo -and
                $test.ErrorRecord[0].CategoryInfo.Reason) -and
                (@("RuntimeException","ParameterBindingValidationException","ParameterBindingException","HttpRequestException","TaskCanceledException") -Contains $test.ErrorRecord[0].CategoryInfo.Reason )) {
            Write-Verbose "Setting result=Error $($name) because: $($test.ErrorRecord[0].CategoryInfo.Reason)"
            $result = "Error"
        }
        else {
            $result = $test.Result
        }

        $timeSpanFormat = 'hh\:mm\:ss'
        $mtTestInfo = [PSCustomObject]@{
            Index           = $testIndex
            Id              = $testId
            Title           = $testTitle
            Name            = $name
            HelpUrl         = $helpUrl
            Severity        = $severity
            Tag             = @($test.Block.Tag + $test.Tag | Select-Object -Unique)
            Result          = $result
            ScriptBlock     = $test.ScriptBlock.ToString()
            ScriptBlockFile = $test.ScriptBlock.File
            ErrorRecord     = $test.ErrorRecord
            Block           = $test.Block.ExpandedName
            Duration        = $test.Duration.ToString($timeSpanFormat)
            ResultDetail    = $testResultDetail
        }
        $mtTests += $mtTestInfo
    }
    # Count all Passed, Failed, Skipped, Error, NotRun and Total results
    $Recount = [PSCustomObject]@{
        FailedCount  = 0
        PassedCount  = 0
        SkippedCount = 0
        NotRunCount  = 0
        ErrorCount   = 0
        TotalCount   = 0
    }
    $Recount.FailedCount = @($mtTests | Where-Object { $_.Result -eq 'Failed' }).Count
    $Recount.PassedCount = @($mtTests | Where-Object { $_.Result -eq 'Passed' }).Count
    $Recount.ErrorCount = @($mtTests | Where-Object { $_.Result -eq 'Error' }).Count
    $Recount.SkippedCount = @($mtTests | Where-Object { $_.Result -eq 'Skipped' }).Count
    $Recount.NotRunCount = @($mtTests | Where-Object { $_.Result -eq 'NotRun' }).Count
    $Recount.TotalCount = $mtTests.Count
    Write-Verbose "Recount: $($Recount | Out-String)"

    $mtBlocks = @()
    foreach ($container in $PesterResults.Containers) {

        foreach ($block in $container.Blocks) {
            $mtBlockInfo = $mtBlocks | Where-Object { $_.Name -eq $block.Name }
            if ($null -eq $mtBlockInfo) {
                Write-Verbose "Recalculating block: $($block.Name)"
                $mtBlockInfo = [PSCustomObject]@{
                    Name         = $block.Name
                    Result       = $block.Result
                    FailedCount  = @($mtTests | Where-Object { $_.Result -eq 'Failed' -and $_.Block -eq $block.name }).Count
                    PassedCount  = @($mtTests | Where-Object { $_.Result -eq 'Passed' -and $_.Block -eq $block.name }).Count
                    ErrorCount   = @($mtTests | Where-Object { $_.Result -eq 'Error' -and $_.Block -eq $block.name }).Count
                    SkippedCount = @($mtTests | Where-Object { $_.Result -eq 'Skipped' -and $_.Block -eq $block.name }).Count
                    NotRunCount  = @($mtTests | Where-Object { $_.Result -eq 'NotRun' -and $_.Block -eq $block.name }).Count
                    TotalCount   = @($mtTests | Where-Object { $_.Block -eq $block.name }).Count
                    Tag          = $block.Tag
                }
                $mtBlocks += $mtBlockInfo
            }
            else {
                # We already seen and counted all blocks
            }
        }
    }

    $mtTestResults = [PSCustomObject][ordered]@{
        Result            = $PesterResults.Result
        FailedCount       = $Recount.FailedCount
        PassedCount       = $Recount.PassedCount
        ErrorCount        = $Recount.ErrorCount
        SkippedCount      = $Recount.SkippedCount
        NotRunCount       = $Recount.NotRunCount
        TotalCount        = $Recount.TotalCount
        ExecutedAt        = GetFormattedDate($PesterResults.ExecutedAt)
        TotalDuration     = $PesterResults.Duration.ToString($timeSpanFormat)
        UserDuration      = $PesterResults.UserDuration.ToString($timeSpanFormat)
        DiscoveryDuration = $PesterResults.DiscoveryDuration.ToString($timeSpanFormat)
        FrameworkDuration = $PesterResults.FrameworkDuration.ToString($timeSpanFormat)
        TenantId          = $tenantId
        TenantName        = $tenantName
        TenantLogos       = GetOrganizationLogos
        Account           = $account
        CurrentVersion    = $currentVersion
        LatestVersion     = $latestVersion
        SystemInfo        = GetSystemInfo
        PowerShellInfo    = GetPowerShellInfo
        LoadedModules     = GetLoadedModules
        InvokeCommand     = $InvokeMaesterCommand
        MgContext         = GetMgContextInfo
        PesterConfig      = GetPesterConfigInfo $PesterConfiguration
        MaesterConfig     = $__MtSession.MaesterConfig
        Tests             = $mtTests
        Blocks            = $mtBlocks
        EndOfJson         = "EndOfJson" # Always leave this as the last property. Used by the script to determine the end of the JSON
    }

    # Add output files information if provided
    if ($OutputFiles) {
        $mtTestResults | Add-Member -MemberType NoteProperty -Name 'OutputFiles' -Value $OutputFiles
    }

    return $mtTestResults
}