BuildScripts/AddTaskFunctions/Add-PesterTestTask.task.ps1


$options = @{
    Name        = 'pester'
    Value       = 'Add-PesterTestTask'
    Description = 'Run Pester tests with the given configuration or defaults'
    Scope       = 'Script'
}

Set-Alias @options
Remove-Variable options -ErrorAction SilentlyContinue
function Add-PesterTestTask {
    [CmdletBinding()]
    param(
        # The name of the Invoke-Build Task
        [Parameter(
            Position = 0,
            Mandatory
        )]
        [string]$Name,

        # The type of tests to run. Tests should be organized by type in folders under
        # the tests directory. Defaults to 'Unit'
        [Parameter(
        )]
        [string]$Type,

        # The type of output pester should show
        [Parameter(
        )]
        [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')]
        [string]$Output = 'Detailed',

        # Generate code coverage metrics
        [Parameter(
        )]
        [switch]$CodeCov,

        # A psd1 configuration file in PesterConfiguration format
        [Parameter(
        )]
        [string]$ConfigurationFile,

        # Do not produce an error code if tests fail
        [Parameter(
        )]
        [switch]$NoErrorOnFail
    )
    begin {
        Write-Debug "`n$('-' * 80)`n-- Begin $($MyInvocation.MyCommand.Name)`n$('-' * 80)"
        $DEFAULT_TEST_TYPE = 'UNIT'

        #! Explicitly set Type if it wasn't given
        if (-not ($PSBoundParameters.ContainsKey('Type'))) {
            $PSBoundParameters.Add('Type', $DEFAULT_TEST_TYPE)
        }

        #-------------------------------------------------------------------------------
        #region Configuration file

        <#
         TODO: Document the default pester config file
         If no configuration file was given, then attempt to find $BuildConfigPath/pester/*<Type>*.config.psd1
        #>

        if (-not ($PSBoundParameters.ContainsKey('ConfigurationFile'))) {
            if (-not ([string]::IsNullorEmpty($BuildConfigPath))) {
                $possiblePesterDirectory = Get-ChildItem $BuildConfigPath -Filter 'pester' -Directory
                if ($null -ne $possiblePesterDirectory) {
                    $options = @{
                        #! if no Type was given, it defaults to 'Unit'
                        Filter = "*$Type*.config.psd1"
                        Path   = $possiblePesterDirectory
                    }

                    $possibleConfigurationFile = Get-ChildItem @options |
                        Select-Object -First 1

                    if ($null -ne $possibleConfigurationFile) {
                        $PSBoundParameters['ConfigurationFile'] = $possibleConfigurationFile
                    }
                }
            }
        }

        #endregion Configuration file
        #-------------------------------------------------------------------------------
    }
    process {

        Add-BuildTask $Name -Data $PSBoundParameters -Source $MyInvocation {
            $DEFAULT_CODECOV_FORMAT = 'JaCoCo'
            $DEFAULT_CODECOV_FILE = 'pester.codecoverage.xml'
            $DEFAULT_OUTPUT_VERBOSITY = 'Detailed'
            $DEFAULT_TEST_RESULT_FORMAT = 'NUnitXml'
            $DEFAULT_TEST_RESULT_FILE = 'test.result.xml'
            $DEFAULT_PESTER_RESULT_FILE = 'pester.result.clixml'

            $pesterOptions = @{}

            logDebug "Running $($Task.Data.Type) pester tests"

            #-------------------------------------------------------------------------------
            #region Run Path

            if ($Task.Data.ContainsKey('ConfigurationFile')) {
                try {
                    logDebug "Attempting to load $($Task.Data.ConfigurationFile)"
                    $pesterOptions = Import-Psd $Task.Data.ConfigurationFile -Unsafe
                    logInfo "Pester configured using '$($Task.Data.ConfigurationFile)'"
                } catch {
                    throw (logError "Could not load $($Task.Data.ConfigurationFile)" -PassThru)
                }
                # If it was loaded then at a minimum, the path needs to be valid first
                if (-not [string]::IsNullorEmpty($pesterOptions.Run.Path)) {
                    if (-not (Test-Path $pesterOptions.Run.Path)) {
                        throw "$($pesterOptions.Run.Path) specified in $($Task.Data.ConfigurationFile) is not a valid path"
                    }
                }
            } else {
                # Set some defaults
                logInfo "No configuration file was given for ($Task.Name)"
                logDebug 'Looking for a path to the Tests'
                $possibleTestsPath = (Join-Path (Get-BuildProperty Tests) $Task.Data.Type)
                if (Test-Path $possibleTestsPath) {
                    logDebug "- Found $($Task.Data.Type) tests path '$possibleTestsPath'"
                    $pesterOptions = @{
                        Run = @{
                            Path = $possibleTestsPath
                        }
                    }
                } elseif (-not ([string]::IsNullorEmpty((Get-BuildProperty Tests)))) {
                    logDebug "- $possibleTestPath does not exist. Found tests path $(Get-BuildProperty Tests)"
                    $pesterOptions = @{
                        Run = @{
                            Path = (Get-BuildProperty Tests)
                        }
                    }
                } else {
                    $currentDirectory = Get-Location
                    logInfo "- Could not determine Pester test path using '$currentDirectory'"
                    $pesterOptions = @{
                        Run = @{
                            Path = $currentDirectory
                        }
                    }

                }

            }

            #endregion Run Path
            #-------------------------------------------------------------------------------

            #-------------------------------------------------------------------------------
            #region Exit code

            if (-not($NoErrorOnFail)) {
                $pesterOptions.Run['Exit'] = $true
            }

            #endregion Exit code
            #-------------------------------------------------------------------------------

            #-------------------------------------------------------------------------------
            #region Code coverage

            #! If the key exists, and is set return that. Otherwise return false
            if (-not ([string]::IsNullorEmpty($pesterOptions.CodeCoverage.Enabled))) {
                $codeCovSetByConfigFile = $pesterOptions.CodeCoverage.Enabled
            } else {
                $codeCovSetByConfigFile = $false
            }

            <#
             Code coverage is determined in one of three ways:
              1. The -CodeCov parameter to this function
              2. The -CodeCov parameter to Invoke-Build
              3. Set to $true in the file passed to -ConfigurationFile
            #>

            if (($Task.Data.CodeCov) -or (Get-BuildProperty CodeCov) -or ($codeCovSetByConfigFile)) {

                #region Enabled
                logInfo 'Code coverage is enabled'
                if (-not ($pesterOptions.ContainsKey('CodeCoverage'))) {
                    logDebug 'No CodeCoverage options exist. Creating key'
                    $pesterOptions['CodeCoverage'] = @{
                        Enabled = $true
                    }
                } else {
                    $pesterOptions.CodeCoverage['Enabled'] = $true
                }
                #endregion Enabled

                #region OutputFormat

                # If -CodeCovFormat was set, use that
                # elseif it is already set in the config use that
                # else use $DEFAULT_CODECOV_FORMAT
                $possibleCodeCovFormat = (Get-BuildProperty CodeCovFormat)
                if (-not ([string]::IsNullOrEmpty($possibleCodeCovFormat))) {
                    logDebug "CodeCovFormat set to '$possibleCodeCovFormat'"
                    $pesterOptions.CodeCoverage['OutputFormat'] = $possibleCodeCovFormat
                } elseif  (-not ([string]::IsNullOrEmpty($pesterOptions.CodeCoverage.OutputFormat))) {
                    logDebug "CodeCoverage.OutputFormat set to $($pesterOptions.CodeCoverage.OutputFormat) in config file"
                } else {
                    logInfo "No code coverage format specified. Using $DEFAULT_CODECOV_FORMAT"
                    $pesterOptions.CodeCoverage['OutputFormat'] = $DEFAULT_CODECOV_FORMAT
                }
                #endregion OutputFormat


                #region OutputPath
                #region Directory
                if ([string]::IsNullOrEmpty($CodeCovDirectory)) {
                    logInfo 'No CodeCovDirectory was set'
                    $possibleCodeCovDirectory = (Get-BuildProperty Artifact)
                    if (-not ([string]::IsNullorEmpty($possibleCodeCovDirectory))) {
                        logDebug "- Setting code coverage directory to `$Artifact"
                        $CodeCovDirectory = $possibleCodeCovDirectory
                    } else {
                        logDebug '- Setting code coverage directory to current directory'
                        $CodeCovDirectory = Get-Location
                    }
                }
                #endregion Directory

                #region File
                if ($CodeCovDirectory | Confirm-Path) {
                    if (-not ([string]::IsNullorEmpty($CodeCovFile))) {
                        logDebug '- CodeCovFile was set'
                        # in the stitch config, the user can use "tokens" for Type and Format in the file name
                        $CodeCovFile = ($CodeCovFile -replace [regex]::Escape('{Type}') , $Task.Data.Type.ToLower())
                        $CodeCovFile = ($CodeCovFile -replace [regex]::Escape('{Format}') , $CodeCovFormat.ToLower())
                    } else {
                        logDebug "- CodeCovFile was not set using default '$DEFAULT_CODECOV_FILE'"
                        $codeCovFile = $DEFAULT_CODECOV_FILE
                    }
                }
                #endregion File

                $codeCovPath = (Join-Path $CodeCovDirectory $CodeCovFile)
                logInfo "Writing Code Coverage to $codeCovPath"
                $pesterOptions.CodeCoverage.OutputPath = $codeCovPath
            }
            #endregion OutputPath

            #region Source Path
            if ([string]::IsNullorEmpty($pesterOptions.CodeCoverage.Path)) {
                logInfo 'CodeCoverage source path is not set'
                $possibleSourcePath = (Get-BuildProperty Source)
                if (-not ([string]::IsNullorEmpty($possibleSourcePath))) {
                    logInfo "- Setting source path to $possibleSourcePath"
                    $pesterOptions.CodeCoverage['Path'] = $possibleSourcePath
                }
            }

            if ([string]::IsNullorEmpty($pesterOptions.CodeCoverage.RecursePaths)) {
                $pesterOptions.CodeCoverage['RecursePaths'] = $true
            }
            #endregion Source Path

            #endregion Code coverage
            #-------------------------------------------------------------------------------


            #-------------------------------------------------------------------------------
            #region Test result
            if ((-not ([string]::IsNullOrEmpty($TestResultDirectory))) -or
                (-not ([string]::IsNullOrEmpty($TestResultFile)))) {
                if (-not ($pesterOptions.ContainsKey('TestResult'))) {
                    $pesterOptions['TestResult'] = @{}
                }
                $pesterOptions.TestResult['Enabled'] = $true

                #region OutputFormat
                $possibleTestResultFormat = (Get-BuildProperty TestResultFormat)
                if (-not ([string]::IsNullOrEmpty($possibleTestResultFormat))) {
                    logDebug "TestresultFormat set to '$possibleTestResultFormat'"
                    $TestResultFormat = $possibleTestResultFormat
                } elseif  (-not ([string]::IsNullOrEmpty($pesterOptions.TestResult.OutputFormat))) {
                    $TestResultFormat = $pesterOptions.TestResult.OutputFormat
                    logDebug "TestResult.OutputFormat set to $($pesterOptions.TestResult.OutputFormat) in config file"
                } else {
                    logInfo "No test result format specified. Using $DEFAULT_TEST_RESULT_FORMAT"
                    $TestResultFormat = $DEFAULT_TEST_RESULT_FORMAT
                }

                $pesterOptions.TestResult['OutputFormat'] = $TestResultFormat
                #endregion OutputFormat

                #region Directory
                if ([string]::IsNullOrEmpty($TestResultDirectory)) {
                    logInfo 'No TestResultDirectory was set'
                    $possibleTestResultDirectory = (Get-BuildProperty Artifact)
                    if (-not ([string]::IsNullorEmpty($possibleTestResultDirectory))) {
                        logDebug "- Setting test result directory to `$Artifact"
                        $TestResultDirectory = $possibleTestResultDirectory
                    } else {
                        logDebug '- Setting test result directory to current directory'
                        $TestResultDirectory = Get-Location
                    }
                }
                #endregion Directory

                #region File
                if ($TestResultDirectory | Confirm-Path) {
                    if (-not ([string]::IsNullorEmpty($TestResultFile))) {
                        logDebug '- TestResultFile was set'
                        # in the stitch config, the user can use "tokens" for Type and Format in the file name
                        $TestResultFile = ($TestResultFile -replace [regex]::Escape('{Type}') , $Task.Data.Type.ToLower())
                        $TestResultFile = ($TestResultFile -replace [regex]::Escape('{Format}') , $TestResultFormat.ToLower())
                    } else {
                        logDebug "- TestResultFile was not set using default '$DEFAULT_TEST_RESULT_FILE'"
                        $TestResultFile = $DEFAULT_TEST_RESULT_FILE
                    }
                }
                #endregion File

                $testResultPath = (Join-Path $TestResultDirectory $TestResultFile)
                logInfo "Writing Pester test results to $testResultPath"
                $pesterOptions.TestResult.OutputPath = $testResultPath
            }

            #endregion Test result
            #-------------------------------------------------------------------------------

            #-------------------------------------------------------------------------------
            #region Output

            #! Output is set by:
            #! if Output parameter to Add-PesterTestTask
            #! elseif PesterOutput parameter
            #! elseif Config file
            #! else $DEFAULT_OUTPUT_VERBOSITY

            if (-not($pesterOptions.ContainsKey('Output'))) {
                $pesterOptions['Output'] = @{}
            }

            if (-not ([string]::IsNullorEmpty($Task.Data.Output))) {
                logDebug "Pester Output set to '$($Task.Data.Output)' by task parameter -Output"
                $pesterOptions.Output['Verbosity'] = $Task.Data.Output
            } elseif (-not ([string]::IsNullOrEmpty((Get-BuildProperty PesterOutput)))) {
                logDebug "Pester Output set to '$(Get-BuildProperty PesterOutput)' by parameter -PesterOutput"
                $pesterOptions.Output['Verbosity'] = (Get-BuildProperty PesterOutput)
            } else {
                logInfo "Output verbosity not set. Using default $DEFAULT_OUTPUT_VERBOSITY"
                $pesterOptions.Output['Verbosity'] = $DEFAULT_OUTPUT_VERBOSITY
            }

            #endregion Output
            #-------------------------------------------------------------------------------

            #-------------------------------------------------------------------------------
            #region Pester result

            if ((-not ([string]::IsNullOrEmpty($PesterResultDirectory))) -or
                (-not ([string]::IsNullOrEmpty($PesterResultFile)))) {
                $pesterOptions.Run.PassThru = $true
                #region Directory
                if ([string]::IsNullOrEmpty($PesterResultDirectory)) {
                    logInfo 'No PesterResultDirectory was set'
                    $possiblePesterResultDirectory = (Get-BuildProperty Artifact)
                    if (-not ([string]::IsNullorEmpty($possiblePesterResultDirectory))) {
                        logDebug "- Setting Pester result directory to `$Artifact"
                        $PesterResultDirectory = $possiblePesterResultDirectory
                    } else {
                        logDebug '- Setting Pester result directory to current directory'
                        $PesterResultDirectory = Get-Location
                    }
                }
                #endregion Directory

                #region File
                if ($PesterResultDirectory | Confirm-Path) {
                    if (-not ([string]::IsNullorEmpty($PesterResultFile))) {
                        logDebug '- PesterResultFile was set'
                        # in the stitch config, the user can use "tokens" for Type and Format in the file name
                        $PesterResultFile = ($PesterResultFile -replace [regex]::Escape('{Type}') , $Task.Data.Type.ToLower())
                    } else {
                        logDebug "- PesterResultFile was not set using default '$DEFAULT_PESTER_RESULT_FILE'"
                        $PesterResultFile = $DEFAULT_PESTER_RESULT_FILE
                    }
                }
                #endregion File

                $pesterResultPath = (Join-Path $PesterResultDirectory $PesterResultFile)
                logInfo "Writing Pester test results to $pesterResultPath"

            }

            #endregion Pester result
            #-------------------------------------------------------------------------------

            logInfo 'Configuration complete. Running Pester'
            try {
                $pesterResult = Invoke-Pester -Configuration (New-PesterConfiguration -Hashtable $pesterOptions)
                if ($pesterResult.Result -ne 'Passed') {
                    throw ('{0} out of {1} Pester tests failed' -f $pesterResult.FailedCount, $pesterResult.TotalCount)
                }
            } catch {
                throw $_.Exception.GetType()
            }


            logInfo 'Pester tests complete'

            if ($null -ne $pesterResultPath) {
                $pesterResult | Export-Clixml $pesterResultPath
                logInfo "Pester Test result saved to $pesterResultPath"
            }

            Remove-Variable pesterConfig, pesterResult, pesterOptions -ErrorAction SilentlyContinue
        }
        Remove-Variable pesterConfig, pesterResult, pesterOptions -ErrorAction SilentlyContinue
    }
    end {
        Write-Debug "`n$('-' * 80)`n-- End $($MyInvocation.MyCommand.Name)`n$('-' * 80)"
    }
}