ISEPester.psm1

#region prefix code
#region content of file Classes
enum Scope {
    ParentScope
    ChildScope
}

enum NotSaved {
    RunFromDisk
    RunFromTemp
}

enum Untitled {
    Ignore
    SaveAsTemp
}

class ISEPesterConfiguration {
    [Scope]$InvokeScope
    [NotSaved]$ActionNotSaved
    [Untitled]$ActionUntitled

    ISEPesterConfiguration () {
        $this.InvokeScope = [Scope]::ParentScope
        $this.ActionNotSaved = [NotSaved]::RunFromDisk
        $this.ActionUntitled = [Untitled]::Ignore
    }

    [ISEPesterConfiguration] Clone () {
        return [ISEPesterConfiguration]@{
            InvokeScope = $this.InvokeScope
            ActionNotSaved = $this.ActionNotSaved
            ActionUntitled = $this.ActionUntitled
        }
    }
}
#endregion
#region content of file ImportModule
try {
    Import-Module -Name Pester -MinimumVersion 5.0 -ErrorAction Stop
} catch {
    Write-Error -Message "Failed to import module Pester - can't continue. Error: $_"
    return
}
#endregion
#region content of file InternalVariables
$script:outputConfiguration = [Pester.OutputConfiguration]::Default
$script:isePesterConfiguration = [ISEPesterConfiguration]::new()
#endregion
#endregion
#region private functions
#region content of file Get-psISE
function Get-psISE {
    <#
        .Synopsis
        Helper function that returns psISE object (needed for testing /mocking)
 
        .Description
        $psISE automatic variable is read-only and can't be replaced inside ISE.
        To prevent not being able to test module inside that host - adding simple function that returns this object.
 
        .Example
        $test = Get-psISE
 
        Saves value of psISE object into variable test.
    #>

    [CmdletBinding()]
    param ()

    $psISE
}
#endregion
#endregion
#region public functions
#region content of file Invoke-ISECurrentTest
function Invoke-ISECurrentTest {
    <#
        .Synopsis
        Function to run tests based on cursor location in editor file.
 
        .Description
        PowerShell ISE allows to see current location of the cursor in editor tab.
        Using that information makes it possible to run a test based on that location.
        Logic is:
        - if the cursor is on the line with container name (It, Context, Describe) that block is called.
        - if the cursor is on the line inside any `It` block - that block only would be called.
 
        .Example
        Invoke-ISECurrentTest
 
        Runs test on the line where cursor in the current file is located.
    #>

    [CmdletBinding()]
    param (
        # Verbosity of output
        [ValidateSet(
            'None',
            'Normal',
            'Detailed',
            'Diagnostic'
        )]
        [String]$Verbosity,

        # Verbosity of stack trace
        [ValidateSet(
            'None',
            'FirstLine',
            'Filtered',
            'Full'
        )]
        [String]$StackTraceVerbosity,

        # The CI format of error output in build logs
        [ValidateSet(
            'None',
            'Auto',
            'AzureDevops',
            'GithubActions'
        )]
        [String]$CIFormat,

        # The scope where scripts should run
        [Scope]$InvokeScope,

        # Behavior for files that were not saved
        [NotSaved]$ActionNotSaved,

        # Behavior for filest that are untitled/ not saved to disk yet
        [Untitled]$ActionUntitled
    )

    $currentIsePesterConfig = $script:isePesterConfiguration.clone()

    foreach (
        $key in @(
            'InvokeScope'
            'ActionNotSaved'
            'ActionUntitled'
        )
    ) {
        if ($PSBoundParameters.ContainsKey($key)) {
            $currentIsePesterConfig.$key = $PSBoundParameters.$key
        }
    }

    try {
        $ise = Get-psISE
    } catch {
        Write-Warning -Message 'Command designed to use in PowerShell ISE'
    }

    if (-not (Get-Module -Name Pester)) {
        try {
            Import-Module -Name Pester -MinimumVersion 5.0 -ErrorAction Stop
        } catch {
            Write-Warning -Message "Failed to import Pester module - $_"
        }
    }

    $tempFile = $null

    if (
        ($file = $ise.CurrentFile) -and
        ($line = $file.Editor.CaretLineText) -and
        ($lineNumber = $file.Editor.CaretLine)
    ) {
        if ($file.IsSaved) {
            $testFilePath = $file.FullPath
        } else {
            if ($file.IsUntitled) {
                if ($currentIsePesterConfig.ActionUntitled -eq [Untitled]::Ignore) {
                    Write-Warning -Message "File not saved and ISEPester configure to ignore Untitled files - can't continue"
                    return
                } else {
                    $tempFile = [IO.Path]::GetTempFileName() |
                        Get-Item |
                        Rename-Item -NewName { '{0}.Tests.ps1' -f $_.Name } -PassThru
                    Set-Content -Path $tempFile.FullName -Value $file.Editor.Text
                    $testFilePath = $tempFile.FullName
                }
            } else {
                if ($currentIsePesterConfig.ActionNotSaved -eq [NotSaved]::RunFromDisk) {
                    Write-Warning -Message "File $($file.FullPath) is not saved - working on current copy on disk!"
                    $testFilePath = $file.FullPath
                } else {
                    $tempFile = [IO.Path]::GetTempFileName() |
                        Get-Item |
                        Rename-Item -NewName { '{0}.Tests.ps1' -f $_.Name } -PassThru
                    Set-Content -Path $tempFile.FullName -Value $file.Editor.Text
                    $testFilePath = $tempFile.FullName
                }
            }
        }

        $config = [PesterConfiguration]@{
            Run = @{
                Path = $testFilePath
            }
        }

        foreach (
            $parameter in @(
                'Verbosity'
                'StackTraceVerbosity'
                'CIFormat'
            )
        ) {
            if ($PSBoundParameters.ContainsKey($parameter)) {
                $config.Output.$parameter = $PSBoundParameters.$parameter
            } else {
                $config.Output.$parameter = $script:outputConfiguration.$parameter
            }
        }
        $parsedTestFile = [System.Management.Automation.Language.Parser]::ParseFile($testFilePath, [ref]$null, [ref]$null)
        $filter = ''
        if ($line -match '\s*(Describe|Context|It)') {
            # lets make sure this is not a comment...
            $myBlock = $parsedTestFile.FindAll(
                {
                    param (
                        $Ast
                    )
                    $Ast.CommandElements -and
                    $Ast.CommandElements[0].Value -in 'It', 'Context', 'Describe' -and
                    $Ast.Extent.StartLineNumber -eq $lineNumber
                },
                $true
            )
            if ($myBlock) {
                $filter = '{0}:{1}' -f $testFilePath, $lineNumber
            }
        }
        if ([String]::IsNullOrEmpty($filter)) {
            $myItBlock = $parsedTestFile.FindAll(
                {
                    param (
                        $Ast
                    )
                    $Ast.CommandElements -and
                    $Ast.CommandElements[0].Value -eq 'It' -and
                    $Ast.Extent.StartLineNumber -le $lineNumber -and
                    $Ast.Extent.EndLineNumber -ge $lineNumber
                },
                $true
            )
            if ($myItBlock) {
                $filter = '{0}:{1}' -f $testFilePath, $myItBlock[0].Extent.StartLineNumber
            } else {
                Write-Warning -Message "Line '$line' at $lineNumber is not inside It block - perhaps $($file.FullPath) is not a test file?"
                return
            }
        }

        $config.Filter.Line = $filter
        if ($currentIsePesterConfig.InvokeScope -eq [Scope]::ParentScope) {
            Invoke-Pester -Configuration $config
        } else {
            & {
                Invoke-Pester -Configuration $config
            }
        }
        if ($tempFile) {
            $tempFile | Remove-Item -Force
        }
    } else {
        Write-Warning -Message 'Not able to figure out cursor position - giving up.'
    }
}
#endregion
#region content of file Set-ISEPesterConfiguration
function Set-ISEPesterConfiguration {
    <#
        .Synopsis
        Function to configure the way Pester tests run in the ISE.
 
        .Description
        Functions allows to configure how tests in context of ISE will behave. It includes:
        - output options
        - scoping (to prevent polluting current scope)
 
        .Example
        Set-ISEPesterConfiguration -Verbosity Detailed -StackTraceVerbosity Filtered
        Changes outpuf of pester calls to:
        - display detailed results
        - show filter view for stack trace
 
        .Example
        Set-ISEPesterConfiguration -Invoke ChildScope
        Configures command to run in the child scope to prevent polluting parent scope.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessage(
        'PSUseShouldProcessForStateChangingFunctions',
        '',
        Justification = 'Changing configuration of the module, not the system state'
    )]
    [CmdletBinding()]
    param (
        # Verbosity of output
        [ValidateSet(
            'None',
            'Normal',
            'Detailed',
            'Diagnostic'
        )]
        [String]$Verbosity,

        # Verbosity of stack trace
        [ValidateSet(
            'None',
            'FirstLine',
            'Filtered',
            'Full'
        )]
        [String]$StackTraceVerbosity,

        # The CI format of error output in build logs
        [ValidateSet(
            'None',
            'Auto',
            'AzureDevops',
            'GithubActions'
        )]
        [String]$CIFormat,

        # The scope where scripts should run
        [Scope]$InvokeScope,

        # Behavior for files that were not saved
        [NotSaved]$ActionNotSaved,

        # Behavior for filest that are untitled/ not saved to disk yet
        [Untitled]$ActionUntitled
    )

    foreach (
        $key in @(
            'Verbosity'
            'StackTraceVerbosity'
            'CIFormat'
        )
    ) {
        if ($PSBoundParameters.ContainsKey($key)) {
            $script:outputConfiguration.$key = $PSBoundParameters.$key
        }
    }

    foreach (
        $key in @(
            'InvokeScope'
            'ActionNotSaved'
            'ActionUntitled'
        )
    ) {
        if ($PSBoundParameters.ContainsKey($key)) {
            $script:isePesterConfiguration.$key = $PSBoundParameters.$key
        }
    }
}
#endregion
#endregion
#region sufix
#region content of file ISEAddOn
try {
    $null = $psise.CurrentPowerShellTab.AddOnsMenu.Submenus.Add(
        'Run in Pester',
        { Invoke-ISECurrentTest },
        'CTRL+F8'
    )
} catch {
    Write-Warning -Message "Failed to add shortcut - $_"
}
#endregion
#endregion