Functions/GenXdev.Coding.PowerShell.Modules/Assert-GenXdevUnitTest.ps1

################################################################################
<#
.SYNOPSIS
Executes unit tests for specified PowerShell modules and cmdlets with detailed
reporting.
 
.DESCRIPTION
This script provides a comprehensive test runner for PowerShell modules and
cmdlets. It offers configurable verbosity levels, interactive debugging of
failed tests, and color-coded output. Results can be displayed directly or
returned as objects for pipeline processing.
 
The script supports filtering by module name or specific cmdlet, handling of
local vs published modules, and various output formatting options.
 
.PARAMETER BaseModuleName
Target modules to test. Accepts wildcards and multiple module names.
Default value: "GenXdev*"
 
.PARAMETER ModuleFilter
Optional filter to exclude certain modules from testing.
 
.PARAMETER CmdletName
Name of specific cmdlet to test. Limits testing scope to just this cmdlet.
 
.PARAMETER NoLocal
When specified, excludes local development versions of modules from testing.
 
.PARAMETER OnlyPublished
When specified, only tests modules that have been published to repositories.
 
.PARAMETER FromScripts
When specified, sources tests from script files rather than module files.
 
.PARAMETER Verbosity
Controls detail level of test output.
Valid values: None, Normal, Detailed, Diagnostic
 
.PARAMETER StackTraceVerbosity
Controls stack trace detail in error output.
Valid values: None, FirstLine, Filtered, Full
 
.PARAMETER AllowLongRunningTests
When specified, includes tests marked as long-running in the test execution.
 
.PARAMETER DebugFailedTests
When specified, enables interactive debugging of failed tests with retry option.
 
.PARAMETER Passthru
When specified, returns test result objects instead of formatted console output.
 
.EXAMPLE
Assert-GenXdevUnitTest -BaseModuleName "MyModule" -Verbosity Detailed `
    -StackTraceVerbosity Full -DebugFailedTests
 
.EXAMPLE
Assert-GenXdevUnitTest "MyModule*" -NoLocal -OnlyPublished
#>

################################################################################
function Assert-GenXdevUnitTest {

    [CmdletBinding(DefaultParameterSetName = "Default")]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseSingularNouns", "Assert-GenXdevUnitTest")]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "Assert-GenXdevUnitTest")]
    [Alias("rungenxdevtests")]
    param (
        ################################################################################
        [Parameter(
            Mandatory = $false,
            Position = 0,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            HelpMessage = "Filter to apply to module names"
        )]
        [ValidateNotNullOrEmpty()]
        [Alias("Module", "ModuleName")]
        [ValidatePattern("^(GenXdev|GenXde[v]\*|GenXdev(\.\w+)+)+$")]
        [string[]] $BaseModuleName = @("GenXdev*"),
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Filter for selecting modules"
        )]
        [string[]]$ModuleFilter = $null,
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Filter for select cmdlets"
        )]
        [Alias("Filter", "CmdLet", "Cmd", "FunctionName", "Name")]
        [string] $CmdletName,
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Excludes local module versions"
        )]
        [switch] $NoLocal,
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Tests only published modules"
        )]
        [switch] $OnlyPublished,
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Sources tests from script files"
        )]
        [switch] $FromScripts,
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Output detail level"
        )]
        [ValidateSet("None", "Normal", "Detailed", "Diagnostic")]
        [string]$Verbosity = "None",
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Stack trace detail level"
        )]
        [ValidateSet("None", "FirstLine", "Filtered", "Full")]
        [string]$StackTraceVerbosity = "FirstLine",
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Also selects unit-tests that have long running durations"
        )]
        [switch]$AllowLongRunningTests,
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Enable interactive test debugging"
        )]
        [switch]$DebugFailedTests,
        ################################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Return result objects"
        )]
        [switch]$Passthru
    )

    begin {

        GenXdev.FileSystem\AssurePester

        GenXdev.Coding\Add-MissingGenXdevUnitTests

        # store allow long running tests setting in script scope
        $Script:AllowLongRunningTests = ($Local:AllowLongRunningTests -eq $true)

        # load required modules
        GenXdev.Helpers\Import-GenXdevModules

        Microsoft.PowerShell.Utility\Write-Verbose "Starting unit test execution"

        # remove debug parameter if present to avoid passing it downstream
        if ($PSBoundParameters.ContainsKey("DebugFailedTests")) {
            $null = $PSBoundParameters.Remove("DebugFailedTests")
        }

        if ($PSBoundParameters.ContainsKey("Passthru")) {
            $null = $PSBoundParameters.Remove("Passthru")
        }

        # define ANSI color codes for output formatting
        $ansiStartForgroundRed = "`e[91m"
        $ansiStartForgroundDarkGreen = "`e[32m"
        $ansiEndForground = "`e[0m"

        # store original verbose preference to restore later
        $origVerbosePref = $VerbosePreference
    }


process {

        $results = $null
        # track overall test success state
        $Script:testSuccess = $true

        try {
            do {
                # execute the actual test runner script
                Microsoft.PowerShell.Utility\Write-Verbose "Executing test runner script"
                try {
                    # copy parameter values to the internal script with matching parameters
                    $identicalParams = GenXdev.Helpers\Copy-IdenticalParamValues `
                        -FunctionName "$PSScriptRoot\_AssertGenXdevUnitTests.ps1" `
                        -BoundParameters $PSBoundParameters

                    $Script:testSuccess = $true
                    $results = (. "$PSScriptRoot\_AssertGenXdevUnitTests.ps1" @identicalParams |
                        Microsoft.PowerShell.Core\ForEach-Object {

                            $PSItem.Tests | Microsoft.PowerShell.Core\ForEach-Object {

                                $PSItem
                                # extract relevant test properties for display
                                $null = $_ |
                                Microsoft.PowerShell.Utility\Select-Object -Property @(
                                    "Block",
                                    "Name",
                                    "ErrorRecord",
                                    "UserDuration",
                                    "Duration",
                                    "Result"
                                ) |
                                Microsoft.PowerShell.Core\ForEach-Object {
                                    $test = @{
                                        Block       = $_.Block
                                        Name        = $_.Name
                                        ErrorRecord = $_.ErrorRecord
                                        Duration    = $_.UserDuration
                                        Result      = $_.Result
                                    }

                                    # format failed tests with red coloring
                                    if ($test.Result -like "*Failed*") {
                                        $Script:testSuccess = $false
                                        $test.Result = (
                                            "$ansiStartForgroundRed$($test.Result)" +
                                            "$ansiEndForground"
                                        )
                                        $test.Block = "$($test.Block)".Replace(
                                            "[-]",
                                            "$ansiStartForgroundRed[❌]$ansiEndForground"
                                        ).Replace(
                                            "[+]",
                                            "$ansiStartForgroundRed[❌]$ansiEndForground"
                                        ).Replace(
                                            "[!]",
                                            "$ansiStartForgroundDarkGreen[❗]$ansiEndForground"
                                        )
                                    }
                                    # format passed tests with green coloring
                                    elseif ($test.Result -eq "Passed") {
                                        $test.Result = (
                                            "$ansiStartForgroundDarkGreen$($test.Result)" +
                                            "$ansiEndForground"
                                        )
                                        $test.Block = "$($test.Block)".Replace(
                                            "[+]",
                                            "$ansiStartForgroundDarkGreen[✅]$ansiEndForground"
                                        ).Replace(
                                            "[-]",
                                            "$ansiStartForgroundDarkGreen[✅]$ansiEndForground"
                                        ).Replace(
                                            "[!]",
                                            "$ansiStartForgroundDarkGreen[✅]$ansiEndForground"
                                        )
                                    }
                                    elseif ($test.Result -eq "Skipped") {
                                        $test.Result = (
                                            "$ansiStartForgroundDarkGreen$($test.Result)" +
                                            "$ansiEndForground"
                                        )
                                        $test.Block = "$($test.Block)".Replace(
                                            "[+]",
                                            "$ansiStartForgroundDarkGreen[❗]$ansiEndForground"
                                        ).Replace(
                                            "[-]",
                                            "$ansiStartForgroundDarkGreen[❗]$ansiEndForground"
                                        ).Replace(
                                            "[!]",
                                            "$ansiStartForgroundDarkGreen[❗]$ansiEndForground"
                                        )
                                    }

                                    $test
                                } | Microsoft.PowerShell.Core\ForEach-Object {
                                    # calculate padding based on console width
                                    [int] $p = [Math]::Min(
                                    ([Console]::WindowWidth - 75) / 2,
                                        20
                                    )

                                    # format output string with fixed column widths
                                    [string] $s = (
                                        "$("$($_.Block)".Substring(0,[Math]::Min("$($_.Block)".Length, 35+$p)).PadRight(35+$p,' ')) " +
                                        "$("$($_.Name)".Substring(0,[Math]::Min("$($_.Name)".Length, 35+$p)).PadRight(35+$p,' ')) " +
                                        "$("$($_.UserDuration)".Substring(0,[Math]::Min("$($_.UserDuration)".Length, 15)).PadRight(15,' ')) " +
                                        "$("$($_.Result)".Substring(0,[Math]::Min("$($_.Result)".Length, 15)).PadRight(15,' ')) "
                                    )

                                    # add error message if present
                                    if ($_.ErrorRecord) {
                                        $s = $s + (
                                            "`r`n$ansiStartForgroundRed" +
                                            "$($_.ErrorRecord.Exception.Message)" +
                                            "$ansiEndForground"
                                        )
                                    }

                                    $s

                                } | Microsoft.PowerShell.Core\Out-Host
                            }
                        }
                    )
                }
                catch {
                    $results = @(Microsoft.PowerShell.Utility\New-Object PSObject -Property @{
                            Block        = @{FailedCount = 1}
                            Name         = ($_.Exception -is [System.Management.Automation.ParseException]) ? [IO.Path]::GetFileName($_.InvocationInfo.ScriptName).Replace(".Tests.ps1", "") : "Error"
                            ErrorRecord  = $_.ErrorRecord
                            UserDuration = "0"
                            Result       = "Failed"
                            ExpandedPath = ($_.Exception -is [System.Management.Automation.ParseException]) ? [IO.Path]::GetFileName($_.InvocationInfo.ScriptName).Replace(".Tests.ps1", "") : ""
                        })
                    if (-not $DebugFailedTests) {

                        throw $results
                    }
                }

                # handle failed test debugging if requested
                if ($DebugFailedTests) {
                    foreach ($result in $results) {
                        if ($result.Result -like "*Failed*") {
                            $Script:testSuccess = $false

                            # extract failed command name for debugging
                            $nextFailedCommand = $result |
                            Microsoft.PowerShell.Core\ForEach-Object {
                                if (($null -eq $_) -or ($null -eq $_.ExpandedPath)) {
                                    return;
                                }
                                $_.ExpandedPath.Replace(" ", ".").Split(". :".ToCharArray())[0]
                            }

                            # show interactive debug prompt with test failure information
                            try {
                                GenXdev.Coding\Assert-GenXdevCmdletTests `
                                    -AssertFailedTest `
                                    -CmdletName $nextFailedCommand `
                                    -Prompt @"
        Unit test failed for unit test: $($result.ExpandedPath)
        The error was: $($result.ErrorRecord.Exception.Message)
"@


                                # prompt user for next action
                                switch ($host.ui.PromptForChoice(
                                        "Make a choice",
                                        "What to do next?",
                                        @("&Stop", "&Test again"),
                                        0)) {
                                    0 { throw "Stopped"; return; }
                                    1 { break }
                                }
                            }
                            catch {

                            }
                        }
                    }
                }

            } while ((-not $Script:testSuccess) -and $DebugFailedTests)
        }
        finally {
            # output results according to Passthru parameter
            if ($Passthru) {
                Microsoft.PowerShell.Utility\Write-Output $results
            }
            else {
                [int] $failures = 0

                # count failed tests in results
                $null = $results |
                Microsoft.PowerShell.Utility\Select-Object -Unique |
                Microsoft.PowerShell.Core\ForEach-Object {
                    $failures += $PSItem.Block.FailedCount
                    Microsoft.PowerShell.Utility\Write-Output $_
                }

                # show summary message with appropriate color
                if ($failures -gt 0) {
                    Microsoft.PowerShell.Utility\Write-Output "`e[91m There were $failures failed tests`e[0m"
                }
                else {
                    Microsoft.PowerShell.Utility\Write-Output "`e[32m All tests passed`e[0m"
                }
            }
        }
    }

    end {
        # restore original verbose preference
        $VerbosePreference = $origVerbosePref
    }
}
################################################################################