resource/scripts/Invoke-MFPester.ps1

#Requires -Module Pester
<#
    .SYNOPSIS
        Run Pester tests for a ModuleForge project with code coverage.

    .DESCRIPTION
        Locates the ModuleForge project root by walking up from the script's own directory,
        discovers test files, and runs Pester with code coverage enabled across all non-test
        function scripts.

        Test discovery:
          - source\functions and source\private are always discovered (the test-alongside-function
            convention - put a function's tests next to it).
          - If a root-level 'tests' folder exists (matched case-insensitively), it is ALSO
            discovered. This is an optional home for integration tests, or for anyone who prefers
            a separate tests folder.
          - Pass -TestPath to override discovery entirely with one or more explicit locations
            (useful for differentiating local vs CI test runs).

        Code coverage:
          - Measured against source\functions and source\private (the project's PowerShell logic).
            Deliberately excluded: classes, enums, validationClasses (Pester class coverage is
            unreliable) and bin, lib, resource (binaries and templates, not logic). The 'tests'
            folder is never added to coverage.
          - .Tests.ps1 and .Skip.ps1 files are always excluded from coverage measurement.
          - Additional filenames can be excluded via the ExcludeFromCoverage parameter.
          - Pass -SkipCodeCoverage to disable coverage entirely. Required on Constrained
            Language Mode systems, where Pester's coverage instrumentation cannot run.

        Sets the working directory to the project root before invoking Pester so that
        test files using the existing get-location convention resolve paths correctly.

        Does not import or depend on ModuleForge. Safe to run in a clean CI environment.

    .EXAMPLE
        .\scripts\Invoke-MFPester.ps1

        Run all Pester tests with code coverage from a clean environment. Auto-detects a
        root 'tests' folder if present.

    .EXAMPLE
        .\scripts\Invoke-MFPester.ps1 -ExcludeFromCoverage 'Get-Something.ps1'

        Run all tests, excluding the named file from code coverage metrics.

    .EXAMPLE
        .\scripts\Invoke-MFPester.ps1 -SkipCodeCoverage

        Run all tests without code coverage. Use this on Constrained Language Mode systems.

    .EXAMPLE
        .\scripts\Invoke-MFPester.ps1 -TestPath '.\tests\Integration'

        Run only the tests under .\tests\Integration, overriding auto-detection.

    .NOTES
        Author: Adrian Andersson
#>

[CmdletBinding()]
PARAM(
    #Filenames (not full paths) to exclude from code coverage measurement
    [Parameter()]
    [string[]]$ExcludeFromCoverage = @(),
    #Pester output verbosity level
    [Parameter()]
    [ValidateSet('None','Normal','Detailed','Diagnostic')]
    [string]$Verbosity = 'Detailed',
    #Explicit test discovery location(s). Overrides the default source\functions + tests auto-detection
    [Parameter()]
    [string[]]$TestPath,
    #Skip code coverage entirely. Required on Constrained Language Mode systems where coverage instrumentation cannot run
    [Parameter()]
    [switch]$SkipCodeCoverage
)

# Walk up from this script's directory to find moduleForgeConfig.xml
$searchPath = $PSScriptRoot
$root = $null
while($searchPath)
{
    if(Get-ChildItem -LiteralPath $searchPath -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq 'moduleForgeConfig.xml' })
    {
        $root = $searchPath
        break
    }
    $parent = Split-Path $searchPath -Parent
    if($parent -eq $searchPath){ break }
    $searchPath = $parent
}

if(-not $root)
{
    throw "Unable to locate 'moduleForgeConfig.xml' in '$PSScriptRoot' or any parent directory. Is this a ModuleForge project?"
}

Write-Verbose "Project root: $root"
$functionsPath = Join-Path $root 'source' 'functions'

if(!(Test-Path $functionsPath))
{
    throw "Functions folder not found at: $functionsPath"
}

$privatePath = Join-Path $root 'source' 'private'

# Resolve test discovery path(s)
if($TestPath)
{
    Write-Verbose "Explicit TestPath supplied, overriding auto-detection"
    $runPaths = $TestPath
}
else
{
    $runPaths = @($functionsPath)
    # source\private is always discovered too, so private tests can live alongside their function
    if(Test-Path $privatePath)
    {
        $runPaths += $privatePath
    }
    # Case-insensitive probe for a root-level 'tests' folder (cross-platform safe)
    $testsFolder = Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq 'tests' } | Select-Object -First 1
    if($testsFolder)
    {
        Write-Verbose "Root 'tests' folder detected at: $($testsFolder.FullName) - adding to discovery"
        $runPaths += $testsFolder.FullName
    }
}
Write-Verbose "Test discovery paths: $($runPaths -join ', ')"

$pesterHashtable = @{
    Run    = @{Passthru = $true; Path = $runPaths}
    Output = @{Verbosity = $Verbosity}
}

if($SkipCodeCoverage)
{
    Write-Verbose 'SkipCodeCoverage set - code coverage disabled'
    $pesterHashtable.CodeCoverage = @{Enabled = $false}
}
else
{
    # Coverage spans source\functions and source\private (the project's PowerShell logic). Classes,
    # enums, validationClasses, bin, lib and resource are deliberately excluded - see header notes.
    $coverageFolders = @($functionsPath)
    if(Test-Path $privatePath){ $coverageFolders += $privatePath }
    $coveragePaths = (Get-ChildItem $coverageFolders -Filter '*.ps1' -Recurse).Where{
        $_.Name -notmatch '\.Tests\.ps1$' -and
        $_.Name -notmatch '\.Skip\.ps1$' -and
        $_.Name -notin $ExcludeFromCoverage
    }.FullName
    Write-Verbose "Coverage files: $($coveragePaths.Count)"
    $pesterHashtable.CodeCoverage = @{Enabled = $true; Path = $coveragePaths}
}

$pesterConfig = New-PesterConfiguration -hashtable $pesterHashtable

$previousLocation = Get-Location
try
{
    Set-Location $root
    Invoke-Pester -Configuration $pesterConfig
}
finally
{
    Set-Location $previousLocation
}