tasks/Invoke-Pester.pester.build.ps1

param
(
    # Project path
    [Parameter()]
    [System.String]
    $ProjectPath = (property ProjectPath $BuildRoot),

    [Parameter()]
    # Base directory of all output (default to 'output')
    [System.String]
    $OutputDirectory = (property OutputDirectory (Join-Path $BuildRoot 'output')),

    [Parameter()]
    [System.String]
    $BuiltModuleSubdirectory = (property BuiltModuleSubdirectory ''),

    [Parameter()]
    [System.Management.Automation.SwitchParameter]
    $VersionedOutputDirectory = (property VersionedOutputDirectory $true),

    [Parameter()]
    [System.String]
    $ProjectName = (property ProjectName ''),

    [Parameter()]
    [System.String]
    $PesterOutputFolder = (property PesterOutputFolder 'testResults'),

    [Parameter()]
    [System.String]
    $PesterOutputFormat = (property PesterOutputFormat ''),

    [Parameter()]
    [System.Object[]]
    $PesterScript = (property PesterScript ''),

    [Parameter()]
    [System.String[]]
    $PesterTag = (property PesterTag @()),

    [Parameter()]
    [System.String[]]
    $PesterExcludeTag = (property PesterExcludeTag @()),

    [Parameter()]
    [System.String]
    $CodeCoverageThreshold = (property CodeCoverageThreshold ''),

    # Build Configuration object
    [Parameter()]
    [System.Collections.Hashtable]
    $BuildInfo = (property BuildInfo @{ })
)

task Import_Pester {
    # This will import the Pester version in the first module folder it finds which will be '/output/RequiredModules'?
    Import-Module -Name 'Pester' -MinimumVersion 4.0 -ErrorAction Stop
}

<#
    Synopsis: Making sure the Module meets some quality standard (help, tests) using Pester 4.
#>

task Invoke_Pester_Tests_v4 {
    <#
        This will evaluate the version of Pester that has been imported into the
        session is v4.x.x.
 
        This is not using task conditioning `-If` because Invoke-Build is evaluate
        the task conditions before it runs any task which means task Import_Pester
        have not had a chance to import the module into the session.
        Also having this evaluation as a task condition will also slow down other
        tasks noticeable.
    #>

    $modulePester = Get-Module -Name 'Pester' |
        Where-Object -FilterScript {
            $_.Version -ge [System.Version] '4.0.0' -and $_.Version -lt [System.Version] '5.0.0'
        }

    # If the correct module is not imported, then exit.
    if (-not $modulePester)
    {
        "Pester 4 is not used in the pipeline, skipping task.`n"

        return
    }

    # Get the vales for task variables, see https://github.com/gaelcolas/Sampler#task-variables.
    . Set-SamplerTaskVariable

    $PesterOutputFolder = Get-SamplerAbsolutePath -Path $PesterOutputFolder -RelativeTo $OutputDirectory

    "`tPester Output Folder = '$PesterOutputFolder"

    if (-not (Test-Path -Path $PesterOutputFolder))
    {
        Write-Build -Color 'Yellow' -Text "Creating folder $PesterOutputFolder"

        $null = New-Item -Path $PesterOutputFolder -ItemType 'Directory' -Force -ErrorAction 'Stop'
    }

    $GetCodeCoverageThresholdParameters = @{
        RuntimeCodeCoverageThreshold = $CodeCoverageThreshold
        BuildInfo                    = $BuildInfo
    }

    $CodeCoverageThreshold = Get-CodeCoverageThreshold @GetCodeCoverageThresholdParameters

    # Initialize default parameters
    $defaultPesterParameters = @{
        PassThru = $true
    }

    $defaultScriptPaths = @(
        'tests',
        (Join-Path -Path $ProjectName -ChildPath 'tests')
    )

    $defaultPesterParameters['Script'] = $defaultScriptPaths
    $defaultPesterParameters['CodeCoverageOutputFileFormat'] = 'JaCoCo'
    $defaultPesterParameters['OutputFormat'] = 'NUnitXML'

    $DefaultExcludeFromCodeCoverage = @('test')

    $pesterCmd = Get-Command -Name 'Invoke-Pester'

    <#
        This will build the Pester* variables (e.g. PesterScript, or
        PesterOutputFormat) in this scope that are used in the rest of the code.
        It will use values for the variables in the following order:
 
        1. Skip creating the variable if a variable is already available because
           it was already set in a passed parameter (Pester*).
        2. Use the value from a property in the build.yaml under the key 'Pester:'.
        3. Use the default value set previously in the variable $defaultPesterParameters.
    #>

    foreach ($paramName in $pesterCmd.Parameters.Keys)
    {
        $taskParamName = "Pester$paramName"

        $pesterBuildConfig = $BuildInfo.Pester

        # Skip if a value was passed as a parameter.
        if (-not (Get-Variable -Name $taskParamName -ValueOnly -ErrorAction 'SilentlyContinue') -and ($pesterBuildConfig))
        {
            $paramValue = $pesterBuildConfig.($paramName)

            # The Variable is set to '' so we should try to use the Config'd one if exists
            if ($paramValue)
            {
                Write-Build -Color 'DarkGray' -Text "Using $taskParamName from Build Config"

                Set-Variable -Name $taskParamName -Value $paramValue
            } # or use a default if available
            elseif ($defaultPesterParameters.ContainsKey($paramName))
            {
                Write-Build -Color 'DarkGray' -Text "Using $taskParamName from Defaults"

                Set-Variable -Name $taskParamName -Value $defaultPesterParameters.($paramName)
            }
        }
        else
        {
            Write-Build -Color 'DarkGray' -Text "Using $taskParamName from Build Invocation Parameters"
        }
    }

    $pesterBuildConfig = $BuildInfo.Pester

    # Code Coverage Exclude
    if (-not $ExcludeFromCodeCoverage -and ($pesterBuildConfig))
    {
        if ($pesterBuildConfig.ContainsKey('ExcludeFromCodeCoverage'))
        {
            $ExcludeFromCodeCoverage = $pesterBuildConfig['ExcludeFromCodeCoverage']
        }
        else
        {
            $ExcludeFromCodeCoverage = $DefaultExcludeFromCodeCoverage
        }
    }

    $testScriptsToOutput = $PesterScript |
        ForEach-Object -Process {
            if ($_ -is [System.Collections.Hashtable])
            {
                Convert-SamplerHashtableToString -Hashtable $_
            }
            else
            {
                $_
            }
        }

    "`tProject Path = $ProjectPath"
    "`tProject Name = $ProjectName"
    "`tTest Scripts = $($testScriptsToOutput -join ', ')"
    "`tTags = $($PesterTag -join ', ')"
    "`tExclude Tags = $($PesterExcludeTag -join ', ')"
    "`tExclude Cov. = $($ExcludeFromCodeCoverage -join ', ')"
    "`tModuleVersion = $ModuleVersion"

    $osShortName = Get-OperatingSystemShortName

    $powerShellVersion = 'PSv.{0}' -f $PSVersionTable.PSVersion

    $getPesterOutputFileFileNameParameters = @{
        ProjectName       = $ProjectName
        ModuleVersion     = $ModuleVersion
        OsShortName       = $osShortName
        PowerShellVersion = $powerShellVersion
    }

    $pesterOutputFileFileName = Get-PesterOutputFileFileName @getPesterOutputFileFileNameParameters
    $pesterOutputFullPath = Join-Path -Path $PesterOutputFolder -ChildPath "$($PesterOutputFormat)_$pesterOutputFileFileName"

    $moduleUnderTest = Import-Module -Name $ProjectName -PassThru
    $PesterCodeCoverage = (Get-ChildItem -Path $moduleUnderTest.ModuleBase -Include @('*.psm1', '*.ps1') -Recurse).Where{
        $result = $true

        foreach ($excludePath in $ExcludeFromCodeCoverage)
        {
            if (-not (Split-Path -IsAbsolute $excludePath))
            {
                $excludePath = Join-Path -Path $moduleUnderTest.ModuleBase -ChildPath $excludePath
            }

            if ($_.FullName -match ([regex]::Escape($excludePath)))
            {
                $result = $false
            }
        }

        $result
    }

    $pesterParams = @{
        PassThru = $true
    }

    $pesterParams['OutputFormat'] = $PesterOutputFormat
    $pesterParams['OutputFile'] = $pesterOutputFullPath

    $getCodeCoverageOutputFile = @{
        BuildInfo          = $BuildInfo
        PesterOutputFolder = $PesterOutputFolder
    }

    $CodeCoverageOutputFile = Get-SamplerCodeCoverageOutputFile @getCodeCoverageOutputFile

    if (-not $CodeCoverageOutputFile)
    {
        $CodeCoverageOutputFile = (Join-Path -Path $PesterOutputFolder -ChildPath "CodeCov_$pesterOutputFileFileName")
    }

    if ($codeCoverageThreshold -gt 0)
    {
        $pesterParams.Add('CodeCoverage', $PesterCodeCoverage)
        $pesterParams.Add('CodeCoverageOutputFile', $CodeCoverageOutputFile)
        $pesterParams.Add('CodeCoverageOutputFileFormat', $PesterCodeCoverageOutputFileFormat)
    }

    "`t"
    "`tCodeCoverage = $($pesterParams['CodeCoverage'])"
    "`tCodeCoverageOutputFile = $($pesterParams['CodeCoverageOutputFile'])"
    "`tCodeCoverageOutputFileFormat = $($pesterParams['CodeCoverageOutputFileFormat'])"

    $codeCoverageOutputFileEncoding = Get-SamplerCodeCoverageOutputFileEncoding -BuildInfo $BuildInfo

    if ($codeCoverageThreshold -gt 0 -and $codeCoverageOutputFileEncoding)
    {
        $pesterParams.Add('CodeCoverageOutputFileEncoding', $codeCoverageOutputFileEncoding)
    }

    "`tCodeCoverageOutputFileEncoding = $($pesterParams['CodeCoverageOutputFileEncoding'])"

    if ($PesterExcludeTag.Count -gt 0)
    {
        $pesterParams.Add('ExcludeTag', $PesterExcludeTag)
    }

    if ($PesterTag.Count -gt 0)
    {
        $pesterParams.Add('Tag', $PesterTag)
    }

    # Test folders is specified, do not run invoke-pester against $BuildRoot
    if ($PesterScript.Count -gt 0)
    {
        $pesterParams.Add('Script', @())

        Write-Build -Color 'DarkGray' -Text " Adding PesterScript to params"

        <#
            Assuming that if the first item in the PesterScript array is of a certain type,
            all other items will be of the same type.
        #>

        switch ($PesterScript[0])
        {
            { $_ -is [System.String] }
            {
                foreach ($testFolder in $PesterScript)
                {
                    if (-not (Split-Path -IsAbsolute $testFolder))
                    {
                        $testFolder = Join-Path -Path $ProjectPath -ChildPath $testFolder
                    }

                    Write-Build -Color 'DarkGray' -Text " ... $testFolder"

                    # The Absolute path to this folder exists, adding to the list of pester scripts to run
                    if (Test-Path -Path $testFolder)
                    {
                        $pesterParams.Script += $testFolder
                    }
                }
            }

            { $_ -is [System.Collections.Hashtable] }
            {
                foreach ($scriptItem in $PesterScript)
                {
                    Write-Build -Color 'DarkGray' -Text " ... $(Convert-SamplerHashtableToString -Hashtable $scriptItem)"

                    $pesterParams.Script += $scriptItem
                }
            }
        }
    }

    # Add all Pester* variables in current scope into the $pesterParams hashtable.
    foreach ($paramName in $pesterCmd.Parameters.keys)
    {
        $paramValueFromScope = (Get-Variable -Name "Pester$paramName" -ValueOnly -ErrorAction 'SilentlyContinue')

        if (-not $pesterParams.ContainsKey($paramName) -and $paramValueFromScope)
        {
            $pesterParams.Add($paramName, $paramValueFromScope)
        }
    }

    if ($codeCoverageThreshold -eq 0 -or (-not $codeCoverageThreshold))
    {
        Write-Build -Color 'DarkGray' -Text "Removing Code Coverage parameters"

        foreach ($CodeCovParam in $pesterParams.Keys.Where{ $_ -like 'CodeCov*' })
        {
            $pesterParams.Remove($CodeCovParam)
        }
    }

    $script:TestResults = Invoke-Pester @pesterParams

    $PesterResultObjectCliXml = Join-Path -Path $PesterOutputFolder -ChildPath "PesterObject_$pesterOutputFileFileName"

    $null = $script:TestResults |
        Export-Clixml -Path $PesterResultObjectCliXml -Force

}

# Synopsis: This task ensures the build job fails if the test aren't successful.
task Fail_Build_If_Pester_Tests_Failed {
    "Asserting that no test failed"
    ""

    # Get the vales for task variables, see https://github.com/gaelcolas/Sampler#task-variables.
    . Set-SamplerTaskVariable

    $PesterOutputFolder = Get-SamplerAbsolutePath -Path $PesterOutputFolder -RelativeTo $OutputDirectory

    "`tPester Output Folder = '$PesterOutputFolder'"

    $osShortName = Get-OperatingSystemShortName

    $GetCodeCoverageThresholdParameters = @{
        RuntimeCodeCoverageThreshold = $CodeCoverageThreshold
        BuildInfo                    = $BuildInfo
    }

    $CodeCoverageThreshold = Get-CodeCoverageThreshold @GetCodeCoverageThresholdParameters

    "`tCode Coverage Threshold = '$CodeCoverageThreshold'"

    $powerShellVersion = 'PSv.{0}' -f $PSVersionTable.PSVersion

    $getPesterOutputFileFileNameParameters = @{
        ProjectName       = $ProjectName
        ModuleVersion     = $ModuleVersion
        OsShortName       = $osShortName
        PowerShellVersion = $powerShellVersion
    }

    $PesterOutputFileFileName = Get-PesterOutputFileFileName @getPesterOutputFileFileNameParameters

    $PesterResultObjectClixml = Join-Path -Path $PesterOutputFolder -ChildPath "PesterObject_$PesterOutputFileFileName"

    Write-Build -Color 'White' -Text "`tPester Output Object = $PesterResultObjectClixml"

    if (-not (Test-Path -Path $PesterResultObjectClixml))
    {
        if ($CodeCoverageThreshold -eq 0)
        {
            Write-Build -Color 'Green' -Text "Pester run and Coverage bypassed. No Pester output found but allowed."

            return
        }
        else
        {
            throw "No command were tested. Threshold of $CodeCoverageThreshold % not met"
        }
    }
    else
    {
        $pesterObject = Import-Clixml -Path $PesterResultObjectClixml -ErrorAction 'Stop'

        Assert-Build -Condition ($pesterObject.FailedCount -eq 0) -Message ('Failed {0} tests. Aborting Build' -f $pesterObject.FailedCount)
    }
}

<#
    Synopsis: Making sure the Module meets some quality standard (help, tests) using Pester 5.
#>

task Invoke_Pester_Tests_v5 {
    <#
        This will evaluate the version of Pester that has been imported into the
        session is v5.0.0 or higher.
 
        This is not using task conditioning `-If` because Invoke-Build is evaluate
        the task conditions before it runs any task which means task Import_Pester
        have not had a chance to import the module into the session.
        Also having this evaluation as a task condition will also slow down other
        tasks noticeable.
    #>

    $isWrongPesterVersion = (Get-Module -Name 'Pester').Version -lt [System.Version] '5.0.0'

    # If the correct module is not imported, then exit.
    if ($isWrongPesterVersion)
    {
        "Pester 5 is not used in the pipeline, skipping task.`n"

        return
    }

    # Get the vales for task variables, see https://github.com/gaelcolas/Sampler#task-variables.
    . Set-SamplerTaskVariable

    $PesterOutputFolder = Get-SamplerAbsolutePath -Path $PesterOutputFolder -RelativeTo $OutputDirectory

    "`tPester Output Folder = '$PesterOutputFolder"

    if (-not (Test-Path -Path $PesterOutputFolder))
    {
        Write-Build -Color 'Yellow' -Text "Creating folder $PesterOutputFolder"

        $null = New-Item -Path $PesterOutputFolder -ItemType 'Directory' -Force -ErrorAction 'Stop'
    }

    $osShortName = Get-OperatingSystemShortName

    $powerShellVersion = 'PSv.{0}' -f $PSVersionTable.PSVersion

    $getPesterOutputFileFileNameParameters = @{
        ProjectName       = $ProjectName
        ModuleVersion     = $ModuleVersion
        OsShortName       = $osShortName
        PowerShellVersion = $powerShellVersion
    }

    $pesterOutputFileFileName = Get-PesterOutputFileFileName @getPesterOutputFileFileNameParameters

    #region Handle deprecated Pester build configuration

    if ($BuildInfo.Pester -and -not $BuildInfo.Pester.Configuration)
    {
        if ($BuildInfo.Pester.Path.Count -ge 1)
        {
            $deprecatedBuildConfigPath = "- " + ($BuildInfo.Pester.Path -join "`n - ")
        }

        if ($BuildInfo.Pester.Tag.Count -ge 1)
        {
            $deprecatedBuildConfigTag = "- " + ($BuildInfo.Pester.Tag -join "`n - ")
        }

        if ($BuildInfo.Pester.ExcludeTag.Count -ge 1)
        {
            $deprecatedBuildConfigExcludeTag = "- " + ($BuildInfo.Pester.ExcludeTag -join "`n - ")
        }

        if ($BuildInfo.Pester.ExcludeFromCodeCoverage.Count -ge 1)
        {
            $buildConfigExcludeFromCodeCoverage = "- " + ($BuildInfo.Pester.ExcludeFromCodeCoverage -join "`n - ")
        }

        Write-Build -Color 'DarkGray' -Text @"
 
-------------------------------------------------------------------------------------
Consider updating the build configuration to the new advanced configuration options:
-------------------------------------------------------------------------------------
# PESTER CONFIG START
Pester:
  # Pester Advanced configuration.
  # If a key is not set it will be using Sampler pipeline default value.
  Configuration:
    Run:
      Path:
        $($deprecatedBuildConfigPath)
      ExcludePath:
    Filter:
      Tag:
        $($deprecatedBuildConfigTag)
      ExcludeTag:
        $($deprecatedBuildConfigExcludeTag)
    Output:
      Verbosity:
    CodeCoverage:
      Path:
      OutputFormat:
      CoveragePercentTarget: $($BuildInfo.Pester.CodeCoverageThreshold)
      OutputPath: $($BuildInfo.Pester.CodeCoverageOutputFile)
      OutputEncoding: $($BuildInfo.Pester.CodeCoverageOutputFileEncoding)
      ExcludeTests:
    TestResult:
      OutputFormat: $($BuildInfo.Pester.OutputFormat)
      OutputPath:
      OutputEncoding:
      TestSuiteName:
  # Sampler pipeline configuration
  ExcludeFromCodeCoverage:
    $($buildConfigExcludeFromCodeCoverage)
# PESTER CONFIG END
-------------------------------------------------------------------------------------
 
"@

    }

    # Set $CodeCoverageOutputFile from deprecated Pester build configuration.
    if ($BuildInfo.Pester.CodeCoverageOutputFile)
    {
        $CodeCoverageOutputFile = $BuildInfo.Pester.CodeCoverageOutputFile

        Write-Build -Color 'DarkGray' -Text "Using deprecated build configuration for CodeCoverageOutputFile as a invocation task parameter."
    }

    # Set $CodeCoverageOutputFileEncoding from deprecated Pester build configuration.
    if ($BuildInfo.Pester.CodeCoverageOutputFileEncoding)
    {
        $CodeCoverageOutputFileEncoding = $BuildInfo.Pester.CodeCoverageOutputFileEncoding

        Write-Build -Color 'DarkGray' -Text "Using deprecated build configuration for CodeCoverageOutputFileEncoding as a invocation task parameter."
    }

    <#
        Set $PesterOutputFormat from deprecated Pester build configuration,
        unless it was provided by the task parameter.
    #>

    if ([System.String]::IsNullOrEmpty($PesterOutputFormat) -and $BuildInfo.Pester.OutputFormat)
    {
        $PesterOutputFormat = $BuildInfo.Pester.OutputFormat

        Write-Build -Color 'DarkGray' -Text "Using deprecated build configuration for OutputFormat as a invocation task parameter."
    }

    <#
        Set $CodeCoverageThreshold from deprecated Pester build configuration,
        unless it was provided by the task parameter.
    #>

    if ([System.String]::IsNullOrEmpty($CodeCoverageThreshold) -and $BuildInfo.Pester.CodeCoverageThreshold)
    {
        $CodeCoverageThreshold = $BuildInfo.Pester.CodeCoverageThreshold

        Write-Build -Color 'DarkGray' -Text "Using deprecated build configuration for CodeCoverageThreshold as a invocation task parameter."
    }

    <#
        Set $PesterScript from deprecated Pester build configuration, unless it
        was provided by the task parameter.
    #>

    if ([System.String]::IsNullOrEmpty($PesterScript) -and $BuildInfo.Pester.Path)
    {
        $PesterScript = [System.Object[]] @($BuildInfo.Pester.Path)

        Write-Build -Color 'DarkGray' -Text "Using deprecated build configuration for Path as a invocation task parameter."
    }

    <#
        Set $PesterTag from deprecated Pester build configuration, unless it was
        provided by the task parameter.
    #>

    if ([System.String]::IsNullOrEmpty($PesterTag) -and $BuildInfo.Pester.Tag)
    {
        $PesterTag = [System.String[]] @($BuildInfo.Pester.Tag)

        Write-Build -Color 'DarkGray' -Text "Using deprecated build configuration for Tag as a invocation task parameter."
    }

    <#
        Set $PesterExcludeTag from deprecated Pester build configuration, unless
        it was provided by the task parameter.
    #>

    if ([System.String]::IsNullOrEmpty($PesterExcludeTag) -and $BuildInfo.Pester.ExcludeTag)
    {
        $PesterExcludeTag = [System.String[]] @($BuildInfo.Pester.ExcludeTag)

        Write-Build -Color 'DarkGray' -Text "Using deprecated build configuration for ExcludeTag as a invocation task parameter."
    }

    #endregion Handle deprecated Pester build configuration

    #region Move values of task parameters to new Pester 5 variable names

    $PesterConfigurationTestResultOutputFormat = $PesterOutputFormat
    $PesterConfigurationRunPath = $PesterScript
    $PesterConfigurationFilterTag = $PesterTag
    $PesterConfigurationFilterExcludeTag = $PesterExcludeTag
    $PesterConfigurationCodeCoverageCoveragePercentTarget = $CodeCoverageThreshold
    $PesterConfigurationCodeCoverageOutputPath = $CodeCoverageOutputFile
    $PesterConfigurationCodeCoverageOutputEncoding = $CodeCoverageOutputFileEncoding

    #endregion Move values of task parameters to new Pester 5 variable names

    #region Set default Pester configuration.

    $defaultPesterParameters = @{
        Configuration = [pesterConfiguration]::Default
    }

    $defaultPesterParameters.Configuration.Run.PassThru = $true
    $defaultPesterParameters.Configuration.Run.Path = @() # Test script path is added later
    $defaultPesterParameters.Configuration.Run.ExcludePath = @()
    $defaultPesterParameters.Configuration.Output.Verbosity = 'Detailed'

    $defaultPesterParameters.Configuration.Filter.Tag = @()
    $defaultPesterParameters.Configuration.Filter.ExcludeTag = @()

    $defaultPesterParameters.Configuration.CodeCoverage.Enabled = $true
    $defaultPesterParameters.Configuration.CodeCoverage.Path = @() # Coverage path is added later
    $defaultPesterParameters.Configuration.CodeCoverage.OutputFormat = 'JaCoCo'
    $defaultPesterParameters.Configuration.CodeCoverage.CoveragePercentTarget = 80
    $defaultPesterParameters.Configuration.CodeCoverage.OutputPath = Join-Path -Path $PesterOutputFolder -ChildPath "CodeCov_$pesterOutputFileFileName"
    $defaultPesterParameters.Configuration.CodeCoverage.OutputEncoding = 'UTF8'
    $defaultPesterParameters.Configuration.CodeCoverage.ExcludeTests = $true # Exclude our own test code from code coverage.

    $defaultPesterParameters.Configuration.TestResult.Enabled = $true
    $defaultPesterParameters.Configuration.TestResult.OutputFormat = 'NUnitXml'
    $defaultPesterParameters.Configuration.TestResult.OutputPath = Get-SamplerAbsolutePath -Path "NUnitXml_$pesterOutputFileFileName" -RelativeTo $PesterOutputFolder
    $defaultPesterParameters.Configuration.TestResult.OutputEncoding = 'UTF8'
    $defaultPesterParameters.Configuration.TestResult.TestSuiteName = $ProjectName

    #endregion Set default Pester configuration.

    $pesterParameters = $defaultPesterParameters.Clone()

    #region Read Pester build configuration

    # Add empty line to output.
    ""

    if ($BuildInfo.Pester)
    {
        $pesterConfigurationSectionNames = ($pesterParameters.Configuration | Get-Member -Type 'Properties').Name

        foreach ($sectionName in $pesterConfigurationSectionNames)
        {
            $propertyNames = ($pesterParameters.Configuration.$sectionName | Get-Member -Type 'Properties').Name

            <#
                This will build the PesterConfiguration<sectionName><parameterName> variables (e.g.
                PesterConfigurationRunPath) in this scope that are used in the rest of the code.
 
                It will use values for the variables in the following order:
 
                1. Skip creating the variable if a variable is already available because it was
                    already set in a passed parameter (e.g. PesterConfigurationRunPath).
                2. Use the value from a property in the build.yaml under the key 'Pester:'.
            #>

            foreach ($propertyName in $propertyNames)
            {
                $taskParameterName = 'PesterConfiguration{0}{1}' -f $sectionName, $propertyName

                $taskParameterValue = Get-Variable -Name $taskParameterName -ValueOnly -ErrorAction 'SilentlyContinue'

                if ([System.String]::IsNullOrEmpty($taskParameterValue))
                {
                    if (-not [System.String]::IsNullOrEmpty($BuildInfo.Pester.Configuration.$sectionName.$propertyName))
                    {
                        $taskParameterValue = $BuildInfo.Pester.Configuration.$sectionName.$propertyName

                        if (-not [System.String]::IsNullOrEmpty($taskParameterValue))
                        {
                            # Use the value from build.yaml.
                            Write-Build -Color 'DarkGray' -Text "Using $taskParameterName from build configuration."

                            Set-Variable -Name $taskParameterName -Value $taskParameterValue
                        }
                    }
                }
                else
                {
                    Write-Build -Color 'DarkGray' -Text "Using $taskParameterName from build invocation task parameter."
                }

                # Set the value in the pester configuration object if it was available.
                if (-not [System.String]::IsNullOrEmpty($taskParameterValue))
                {
                    <#
                        Force conversion from build configuration types to
                        correct Pester type to avoid exceptions like:
 
                        ERROR: Exception setting "ExcludeTag": "Cannot convert
                        the "System.Collections.Generic.List`1[System.Object]"
                        value of type "System.Collections.Generic.List`1[[System.Object,
                        System.Private.CoreLib, Version=5.0.0.0, Culture=neutral,
                        PublicKeyToken=7cec85d7bea7798e]]" to type
                        "Pester.StringArrayOption"."
                    #>

                    $pesterConfigurationValue = switch ($pesterParameters.Configuration.$sectionName.$propertyName)
                    {
                        {$_ -is [Pester.StringArrayOption]}
                        {
                            [Pester.StringArrayOption] @($taskParameterValue)
                        }

                        {$_ -is [Pester.StringOption]}
                        {
                            [Pester.StringOption] $taskParameterValue
                        }

                        {$_ -is [Pester.BoolOption]}
                        {
                            [Pester.BoolOption] $taskParameterValue
                        }

                        {$_ -is [Pester.DecimalOption]}
                        {
                            <#
                                The type [Pester.DecimalOption] cannot convert directly
                                from string.
 
                                Depending where the value come from, this will convert the
                                $taskParameterValue from string to [System.Decimal], then
                                convert it to [Pester.DecimalOption]. An example is if the
                                task parameter $CodeCoverageThreshold is passed on the
                                command line.
                            #>

                            [Pester.DecimalOption] [System.Decimal] $taskParameterValue
                        }

                        Default
                        {
                            <#
                                Set the value without conversion so that new types that
                                are not supported can be catched.
                            #>

                            $pesterConfigurationValue = $taskParameterValue
                        }
                    }

                    <#
                        If the conversion above is not made above this will fail, for example
                        this row will fail if there is a new type that is not handle above.
                    #>

                    $pesterParameters.Configuration.$sectionName.$propertyName = $pesterConfigurationValue
                }
            }
        }
    }

    # Set $ExcludeFromCodeCoverage from Pester build configuration.
    $ExcludeFromCodeCoverage = [System.String[]] @($BuildInfo.Pester.ExcludeFromCodeCoverage)

    <#
        Make sure paths are absolut paths, except for Test Scripts and Code Coverage paths,
        those are handle further down.
    #>


    if ($PesterConfigurationCodeCoverageOutputPath)
    {
        $PesterConfigurationCodeCoverageOutputPath = Get-SamplerAbsolutePath -Path $PesterConfigurationCodeCoverageOutputPath -RelativeTo $PesterOutputFolder
        $pesterParameters.Configuration.CodeCoverage.OutputPath = $PesterConfigurationCodeCoverageOutputPath
    }

    if ($PesterConfigurationTestResultOutputPath)
    {
        $PesterConfigurationTestResultOutputPath = Get-SamplerAbsolutePath -Path $PesterConfigurationTestResultOutputPath -RelativeTo $PesterOutputFolder
        $pesterParameters.Configuration.TestResult.OutputPath = $PesterConfigurationTestResultOutputPath
    }

    # Add empty line to output
    ""

    #endregion Read Pester build configuration

    "`tPester Exclude Path = $($pesterParameters.Configuration.Run.ExcludePath.Value -join ', ')"
    "`tPester Exclude Tags = $($pesterParameters.Configuration.Filter.ExcludeTag.Value -join ', ')"
    "`tPester Tags = $($pesterParameters.Configuration.Filter.Tag.Value -join ', ')"
    "`tPester Verbosity = $($pesterParameters.Configuration.Output.Verbosity.Value)"

    # Import the module that should be tested.
    $moduleUnderTest = Import-Module -Name $ProjectName -PassThru

    # Disable code coverage if threshold is set to 0 or not set at all.
    if ($PesterConfigurationCodeCoverageCoveragePercentTarget -eq 0 -or -not $PesterConfigurationCodeCoverageCoveragePercentTarget)
    {
        $pesterParameters.Configuration.CodeCoverage.Enabled = $false

        Write-Build -Color 'DarkGray' -Text "Disabling Code Coverage."
    }
    else
    {
        # If there is no code coverage path yet, use default - all .psm1 and .ps1 in built module root.
        if (-not $pesterParameters.Configuration.CodeCoverage.Path.Value)
        {
            $defaultCodeCoveragePaths = (Get-ChildItem -Path $moduleUnderTest.ModuleBase -Include @('*.psm1', '*.ps1') -Recurse).Where{
                $result = $true

                if ($ExcludeFromCodeCoverage)
                {
                    foreach ($excludePath in $ExcludeFromCodeCoverage)
                    {
                        if (-not (Split-Path -IsAbsolute $excludePath))
                        {
                            $excludePath = Join-Path -Path $moduleUnderTest.ModuleBase -ChildPath $excludePath
                        }

                        if ($_.FullName -match ([regex]::Escape($excludePath)))
                        {
                            $result = $false
                        }
                    }
                }

                $result
            }

            $pesterParameters.Configuration.CodeCoverage.Path = @($defaultCodeCoveragePaths.FullName)
        }

        ""
        "`tPester Code Coverage Source Path = $($pesterParameters.Configuration.CodeCoverage.Path.Value -join ', ')"
        "`tPester Exclude Code Coverage Path = $($ExcludeFromCodeCoverage -join ', ')"
        "`tPester Exclude Tests Source Path = $($pesterParameters.Configuration.CodeCoverage.ExcludeTests.Value)"
        "`tPester Code Coverage Output Path = $($pesterParameters.Configuration.CodeCoverage.OutputPath.Value)"
        "`tPester Code Coverage Output Format = $($pesterParameters.Configuration.CodeCoverage.OutputFormat.Value)"
        "`tPester Code Coverage Output Encoding = $($pesterParameters.Configuration.CodeCoverage.OutputEncoding.Value)"
        "`tPester Code Coverage Percent Threshold = $($pesterParameters.Configuration.CodeCoverage.CoveragePercentTarget.Value)"
    }

    if ($pesterParameters.Configuration.TestResult.Enabled.Value)
    {
        ""
        "`tPester Test Result Test Suite Name = $($pesterParameters.Configuration.TestResult.TestSuiteName.Value)"
        "`tPester Test Result Output Path = $($pesterParameters.Configuration.TestResult.OutputPath.Value)"
        "`tPester Test Result Output Format = $($pesterParameters.Configuration.TestResult.OutputFormat.Value)"
        "`tPester Test Result Output Encoding = $($pesterParameters.Configuration.TestResult.OutputEncoding.Value)"
        "`tPester Test Result Percent Threshold = $($pesterParameters.Configuration.CodeCoverage.CoveragePercentTarget.Value)"
    }
    else
    {
        Write-Build -Color 'DarkGray' -Text "Disabling Test Results."
    }

    <#
        TODO: Support test scripts that requires parameters. Those scripts need
              to be added as a PesterContainer (for each path). Code below need
              to handle this in the future.
    #>


    # Evaluate if there is any test script provided from task parameter or build config.
    if ([System.String]::IsNullOrEmpty($PesterConfigurationRunPath))
    {
        # Use the default, search project path recursively for tests.
        $pesterParameters.Configuration.Run.Path = @(
            Join-Path -Path $ProjectPath -ChildPath 'tests'
        )
    }
    else
    {
        # Specific test folders are specified.
        $pesterParameters.Configuration.Run.Path = @()

        foreach ($testFolder in $PesterConfigurationRunPath)
        {
            if (-not (Split-Path -IsAbsolute $testFolder))
            {
                $testFolder = Join-Path -Path $ProjectPath -ChildPath $testFolder
            }

            # The absolute path to this folder exists, adding to the list of pester scripts to run
            if (Test-Path -Path $testFolder)
            {
                <#
                    It is not possible to easily add paths to the Path property
                    because it throws the error "[Pester.StringArrayOption] does
                    not contain a method named 'op_Addition'".
                #>

                $existingRunPaths = [System.String[]] @($pesterParameters.Configuration.Run.Path.Value)

                $pesterParameters.Configuration.Run.Path = ($existingRunPaths += $testFolder)
            }
        }
    }

    ""
    "`tPester Test Scripts = $($pesterParameters.Configuration.Run.Path.Value -join ', ')"

    $script:TestResults = Invoke-Pester @pesterParameters

    $PesterResultObjectCliXml = Join-Path -Path $PesterOutputFolder -ChildPath "PesterObject_$pesterOutputFileFileName"

    $null = $script:TestResults |
        Export-Clixml -Depth 5 -Path $PesterResultObjectCliXml -Force
}

# Synopsis: Fails the build if the code coverage is under predefined threshold.
task Pester_If_Code_Coverage_Under_Threshold {
    $GetCodeCoverageThresholdParameters = @{
        RuntimeCodeCoverageThreshold = $CodeCoverageThreshold
        BuildInfo                    = $BuildInfo
    }

    $CodeCoverageThreshold = Get-CodeCoverageThreshold @GetCodeCoverageThresholdParameters

    if (-not $CodeCoverageThreshold)
    {
        $CodeCoverageThreshold = 0
    }

    if ($CodeCoverageThreshold -eq 0)
    {
        Write-Build -Color 'DarkGray' -Text 'Code Coverage has been disabled, skipping task.'

        return
    }

    # Get the vales for task variables, see https://github.com/gaelcolas/Sampler#task-variables.
    . Set-SamplerTaskVariable

    "`tCode Coverage Threshold = '$CodeCoverageThreshold'"

    $PesterOutputFolder = Get-SamplerAbsolutePath -Path $PesterOutputFolder -RelativeTo $OutputDirectory

    "`tPester Output Folder = '$PesterOutputFolder'"

    if (-not (Split-Path -IsAbsolute $PesterOutputFolder))
    {
        $PesterOutputFolder = Join-Path -Path $OutputDirectory -ChildPath $PesterOutputFolder
    }

    $osShortName = Get-OperatingSystemShortName

    $powerShellVersion = 'PSv.{0}' -f $PSVersionTable.PSVersion

    $getPesterOutputFileFileNameParameters = @{
        ProjectName       = $ProjectName
        ModuleVersion     = $ModuleVersion
        OsShortName       = $osShortName
        PowerShellVersion = $powerShellVersion
    }

    $PesterOutputFileFileName = Get-PesterOutputFileFileName @getPesterOutputFileFileNameParameters

    $PesterResultObjectClixml = Join-Path $PesterOutputFolder "PesterObject_$PesterOutputFileFileName"

    Write-Build -Color 'White' -Text "`tPester Output Object = $PesterResultObjectClixml"

    if (-not (Test-Path -Path $PesterResultObjectClixml))
    {
        if ($CodeCoverageThreshold -eq 0)
        {
            Write-Build -Color 'Green' -Text 'Pester run and Coverage bypassed. No Pester output found but allowed.'

            return
        }
        else
        {
            throw "No command were tested. Threshold of $CodeCoverageThreshold % not met"
        }
    }
    else
    {
        $pesterObject = Import-Clixml -Path $PesterResultObjectClixml
    }

    $reachedCoverageThreshold = $true

    # The Version property only exist in the Pester object returned from Pester 5.
    if ([System.String]::IsNullOrEmpty($pesterObject.Version))
    {
        # Pester 4

        if ($pesterObject.CodeCoverage.NumberOfCommandsAnalyzed)
        {
            $coverage = $pesterObject.CodeCoverage.NumberOfCommandsExecuted / $pesterObject.CodeCoverage.NumberOfCommandsAnalyzed * 100

            if ($coverage -lt $CodeCoverageThreshold)
            {
                $reachedCoverageThreshold = $false
            }
        }
    }
    else
    {
        # Pester 5

        # Convert to Decimal so if CoveragePercent is $null it returns as 0.
        $coverage = [System.Decimal] $pesterObject.CodeCoverage.CoveragePercent

        if ($coverage -lt $CodeCoverageThreshold)
        {
            $reachedCoverageThreshold = $false
        }
    }

    if ($reachedCoverageThreshold)
    {
        Write-Build -Color Green -Text ('Code Coverage SUCCESS with value of {0:0.##} % (threshold {1:0.##} %)' -f $coverage, $CodeCoverageThreshold)
    }
    else
    {
        throw ('Code Coverage FAILURE: {0:0.##} % is under the threshold of {1:0.##} %.' -f $coverage, $CodeCoverageThreshold)
    }
}

# Synopsis: Uploading Unit Test results to AppVeyor.
task Upload_Test_Results_To_AppVeyor -If { (property BuildSystem 'unknown') -eq 'AppVeyor' } {
    # Get the vales for task variables, see https://github.com/gaelcolas/Sampler#task-variables.
    . Set-SamplerTaskVariable

    $PesterOutputFolder = Get-SamplerAbsolutePath -Path $PesterOutputFolder -RelativeTo $OutputDirectory

    "`tPester Output Folder = '$PesterOutputFolder'"

    if (-not (Test-Path -Path $PesterOutputFolder))
    {
        Write-Build -Color 'Yellow' -Text "Creating folder $PesterOutputFolder"

        $null = New-Item -Path $PesterOutputFolder -ItemType Directory -Force -ErrorAction 'Stop'
    }

    $osShortName = Get-OperatingSystemShortName

    $powerShellVersion = 'PSv.{0}' -f $PSVersionTable.PSVersion

    $getPesterOutputFileFileNameParameters = @{
        ProjectName       = $ProjectName
        ModuleVersion     = $ModuleVersion
        OsShortName       = $osShortName
        PowerShellVersion = $powerShellVersion
    }

    $pesterOutputFileFileName = Get-PesterOutputFileFileName @getPesterOutputFileFileNameParameters

    $pesterOutputFullPath = Join-Path -Path $PesterOutputFolder -ChildPath "$($PesterOutputFormat)_$pesterOutputFileFileName"

    $testResultFile = Get-Item -Path $pesterOutputFullPath -ErrorAction 'Ignore'

    if ($testResultFile)
    {
        Write-Build -Color 'Green' -Text " Uploading test results $testResultFile to Appveyor"

        $testResultFile | Add-TestResultToAppveyor

        Write-Build -Color 'Green' -Text " Upload Complete"
    }
}

# Synopsis: List the run time for each Pester test.
task Pester_Run_Times {
    <#
        This will evaluate the version of Pester that has been imported into the
        session is v5.0.0 or higher.
 
        This is not using task conditioning `-If` because Invoke-Build is evaluate
        the task conditions before it runs any task which means task Import_Pester
        have not had a chance to import the module into the session.
        Also having this evaluation as a task condition will also slow down other
        tasks noticeable.
    #>

    $isWrongPesterVersion = (Get-Module -Name 'Pester').Version -lt [System.Version] '5.0.0'

    # If the correct module is not imported, then exit.
    if ($isWrongPesterVersion)
    {
        "Pester 5 is not used in the pipeline, skipping task.`n"

        return
    }

    # Get the vales for task variables, see https://github.com/gaelcolas/Sampler#task-variables.
    . Set-SamplerTaskVariable

    $PesterOutputFolder = Get-SamplerAbsolutePath -Path $PesterOutputFolder -RelativeTo $OutputDirectory

    $getPesterOutputFileFileNameParameters = @{
        ProjectName       = $ProjectName
        ModuleVersion     = $ModuleVersion
        OsShortName       = Get-OperatingSystemShortName
        PowerShellVersion = ('PSv.{0}' -f $PSVersionTable.PSVersion)
    }

    $PesterOutputFileFileName = Get-PesterOutputFileFileName @getPesterOutputFileFileNameParameters

    $PesterResultObjectClixml = Join-Path $PesterOutputFolder "PesterObject_$PesterOutputFileFileName"

    "`tPester Output Folder = {0}" -f $PesterOutputFolder
    "`tPester Output Object = {0}" -f $PesterResultObjectClixml
    ""

    if ((Test-Path -Path $PesterResultObjectClixml))
    {
        $pesterObject = Import-Clixml -Path $PesterResultObjectClixml

        $pesterObject.Containers |
            ForEach-Object -Process {
                $durationString = $_.Duration.ToString("''m' minutes 's' seconds'")
                $durationString = $durationString -replace '0 minutes '
                $durationString = $durationString -replace '1 minutes ', '1 minute '
                $durationString = $durationString -replace '1 seconds', '1 second '

                [PSCustomObject] @{
                    Name = $_.Item.Name
                    Duration = $durationString
                    Result = $_.Result
                    Passed = $_.PassedCount
                    Failed = $_.FailedCount
                    Skipped = $_.SkippedCount
                    Total = $_.TotalCount
                }
            } |
            Format-Table -Property @(
                @{
                    Name = 'Test Script File'
                    Expression = { $_.Name }
                }
                @{
                    Name = 'Duration'
                    Expression = { $_.Duration }
                    Align = 'Right'
                }
                @{
                    Name = 'Result'
                    Expression = { $_.Result }
                    Align = 'Right'
                }
                'Passed'
                'Failed'
                'Skipped'
                'Total'
            )

        ""
        "Total run time: {0} ({1} tests was run)" -f $pesterObject.Duration.ToString("''m' minutes 's' seconds'"), $pesterObject.TotalCount
        ""
    }
    else
    {
        Write-Warning -Message 'Pester result object not found.'
    }
}

# Synopsis: Meta task that runs Quality Tests, and fails if they're not successful
task Pester_Tests_Stop_On_Fail Import_Pester, Invoke_Pester_Tests_v4, Invoke_Pester_Tests_v5, Upload_Test_Results_To_AppVeyor, Pester_Run_Times, Fail_Build_If_Pester_Tests_Failed