Engines/Terraform/Invoke-AvmTerraformTestSuite.ps1

function Invoke-AvmTerraformTestSuite {
    <#
    .SYNOPSIS
        Run 'terraform test' for a single tier (unit or integration) against
        the resolved module root.

    .DESCRIPTION
        Engine implementation shared by Invoke-AvmTestUnit and
        Invoke-AvmTestIntegration. Resolves the 'terraform' binary via
        Resolve-AvmTool, then:

          1. Enumerates test targets: the module root plus each immediate
             'modules/*' subdirectory. A target is runnable when it ships at
             least one '*.tftest.hcl' under tests/<tier>/. If no target is
             runnable, returns a pass envelope with FilesProcessed = 0 without
             invoking terraform.
          2. Rejects shell hooks fail-fast: any tests/<tier>/setup.sh or
             teardown.sh throws AvmConfigurationException before terraform runs
             (this engine is PowerShell-only).
          3. Per runnable target (cwd = target root): runs an optional
             tests/<tier>/setup.ps1 hook in an isolated pwsh subprocess; a
             failing hook records an issue and skips that target. Then, unless
             -NoInit was passed, runs
                 terraform init -backend=false -upgrade=false -input=false
                                -test-directory=tests/<tier>
             followed by
                 terraform test -test-directory=tests/<tier> -no-color -json
             Both calls carry -test-directory: init only scans the *default*
             test directory when resolving modules declared in a run block, so
             without it a 'run { module { source = ... } }' helper is never
             installed and terraform test dies with 'Module not installed'.
          4. Parses each target's newline-delimited JSON stream into the shared
             Issue shape (failing 'test_run' entries and error-level
             'diagnostic' entries), prefixing submodule paths with
             'modules/<name>/', and renders a live progress line per test run.

        Status is 'fail' when any target reports a failing/errored run or a
        setup.ps1 hook fails; otherwise 'pass'. A terraform init failure or an
        unexpected 'terraform test' exit code is rethrown as AvmProcessException.

    .PARAMETER Context
        Module context produced by Get-AvmModuleContext. Must have
        Ecosystem='terraform'.

    .PARAMETER Tier
        Which test tier to run: 'unit' or 'integration'. Selects the
        tests/<tier>/ directory passed to -test-directory.

    .PARAMETER AllowPathFallback
        Pass through to Resolve-AvmTool.

    .PARAMETER NoInit
        Skip the implicit 'terraform init'. Use when init is genuinely
        impossible (offline + no cached providers) or when the caller has
        already run it. Init is otherwise always run: the presence of
        '.terraform/' only proves init ran at some point, not that it ran
        against the module sources and providers the current configuration
        (including its .tftest.hcl files) requires.

    .OUTPUTS
        pscustomobject with Engine, Tool, ToolPath, ToolSource, Status,
        FilesProcessed, RunsTotal, RunsPassed, RunsFailed, Issues.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        $Context,

        [Parameter(Mandatory)]
        [ValidateSet('unit', 'integration')]
        [string] $Tier,

        [switch] $AllowPathFallback,

        [switch] $NoInit
    )

    Set-StrictMode -Version 3.0
    $ErrorActionPreference = 'Stop'

    if ($Context.Ecosystem -ne 'terraform') {
        throw [System.ArgumentException]::new(
            "Invoke-AvmTerraformTestSuite requires a terraform context (got Ecosystem='$($Context.Ecosystem)').")
    }

    $tool = Resolve-AvmTool -Name 'terraform' -AllowPathFallback:$AllowPathFallback

    $targets = @(Get-AvmTerraformTestTarget -Root $Context.Root -Tier $Tier)

    if ($targets.Count -eq 0) {
        Write-AvmLog ('no {0} tests found under tests/{0}' -f $Tier) -Level Warning
        # A tier that ran nothing is 'skipped', never 'pass': inside a gauntlet a
        # pass is indistinguishable from a real one, which is how a module with no
        # tests stays green forever. 'skipped' does not flip the overall status, so
        # this reports the gap without breaking modules that ship no tier.
        return [pscustomobject][ordered]@{
            Engine         = 'terraform'
            Tool           = ('{0}/{1}' -f $tool.Name, $tool.Version)
            ToolPath       = $tool.Path
            ToolSource     = $tool.Source
            Status         = 'skipped'
            FilesProcessed = 0
            RunsTotal      = 0
            RunsPassed     = 0
            RunsFailed     = 0
            Issues         = @()
        }
    }

    # Governance ships setup.sh and setup.ps1 side by side, so a .sh is only a
    # misconfiguration when it has no .ps1 counterpart - that is the case where
    # the hook would silently never run.
    $shHooks = New-Object System.Collections.Generic.List[string]
    foreach ($target in $targets) {
        $relPrefix = if ($target.Rel) { $target.Rel + '/' } else { '' }
        foreach ($hookName in @('setup', 'teardown')) {
            $hookDir = Join-Path $target.Path (Join-Path 'tests' $Tier)
            $shPath = Join-Path $hookDir ('{0}.sh' -f $hookName)
            $ps1Path = Join-Path $hookDir ('{0}.ps1' -f $hookName)
            if ((Test-Path -LiteralPath $shPath -PathType Leaf) -and -not (Test-Path -LiteralPath $ps1Path -PathType Leaf)) {
                $shHooks.Add(('{0}tests/{1}/{2}.sh' -f $relPrefix, $Tier, $hookName))
            }
        }
    }
    if ($shHooks.Count -gt 0) {
        throw [AvmConfigurationException]::new(
            ("The terraform {0} test engine runs PowerShell setup hooks only; add a '.ps1' counterpart for these shell hooks: {1}" -f $Tier, ($shHooks -join ', ')))
    }

    $pwshPath = [Environment]::ProcessPath
    if ([string]::IsNullOrWhiteSpace($pwshPath)) {
        $pwshCmd = Get-Command -Name 'pwsh' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
        $pwshPath = if ($pwshCmd) { $pwshCmd.Source } else { 'pwsh' }
    }

    # terraform accepts forward slashes in -test-directory on every platform.
    $testDir = ('tests/{0}' -f $Tier)
    $issues = New-Object System.Collections.Generic.List[object]
    $filesProcessed = 0
    $runsTotal = 0
    $runsPassed = 0
    $runsFailed = 0
    $anyFail = $false

    foreach ($target in $targets) {
        $targetDir = $target.Path
        $relPrefix = if ($target.Rel) { $target.Rel + '/' } else { '' }
        $filesProcessed += $target.Files.Count

        $setup = Invoke-AvmTerraformSetupHook `
            -PwshPath $pwshPath `
            -HookPath (Join-Path $targetDir (Join-Path 'tests' (Join-Path $Tier 'setup.ps1'))) `
            -WorkingDirectory $targetDir `
            -StreamOutput
        if ($null -ne $setup -and $setup.ExitCode -ne 0) {
            $detail = if ($setup.StdErr) { $setup.StdErr.Trim() } elseif ($setup.StdOut) { $setup.StdOut.Trim() } else { '' }
            $issues.Add([pscustomobject][ordered]@{
                    File     = ('{0}{1}/setup.ps1' -f $relPrefix, $testDir)
                    Line     = 0
                    Column   = 0
                    Severity = 'error'
                    Code     = ''
                    Message  = ('setup.ps1 hook failed (exit {0}): {1}' -f $setup.ExitCode, $detail)
                })
            $anyFail = $true
            continue
        }

        $envVars = ConvertFrom-AvmDotEnv -Path (Join-Path $targetDir '.env')

        if (-not $NoInit) {
            $initResult = Invoke-AvmProcess `
                -FilePath $tool.Path `
                -ArgumentList @('init', '-backend=false', '-upgrade=false', '-input=false', '-no-color', ('-test-directory={0}' -f $testDir)) `
                -WorkingDirectory $targetDir `
                -EnvVars $envVars `
                -StreamOutput `
                -Label ('terraform init {0}' -f $targetDir) `
                -IgnoreExitCode

            if ($initResult.ExitCode -ne 0) {
                $detail = if ($initResult.StdErr) { $initResult.StdErr.Trim() } else { $initResult.StdOut.Trim() }
                throw [AvmProcessException]::new(
                    ('terraform init failed with exit code {0}: {1}' -f $initResult.ExitCode, $detail))
            }
        }

        $runStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
        # Must stay a plain scriptblock: .GetNewClosure() rebinds it to a fresh
        # dynamic module, so module-private helpers like Read-AvmTerraformTestRunEvent
        # stop resolving. Unit tests mock Invoke-AvmProcess and never invoke this
        # callback, so only the component tier catches a regression here.
        $progress = {
            param($line)
            $runEvent = Read-AvmTerraformTestRunEvent -Line $line
            if ($null -eq $runEvent) { return }
            $elapsed = $runStopwatch.Elapsed
            $runStopwatch.Restart()
            $name = if ($runEvent.Name) { $runEvent.Name } else { '(unnamed)' }
            $where = if ($runEvent.Path) { '{0} ' -f $runEvent.Path } else { '' }
            # F41: a failing run is narration, not a diagnostic. Writing it at
            # Error level turned every failing run into a GitHub Actions
            # annotation, so the positionless progress line was shown first and
            # the anchored diagnostic last, behind the 10-per-step cap.
            Write-AvmLog (' run {0}"{1}" -> {2} ({3})' -f $where, $name, $runEvent.Status, (Format-AvmDuration -Duration $elapsed))
        }

        $result = Invoke-AvmProcess `
            -FilePath $tool.Path `
            -ArgumentList @('test', ('-test-directory={0}' -f $testDir), '-no-color', '-json') `
            -WorkingDirectory $targetDir `
            -EnvVars $envVars `
            -StreamOutput `
            -OnStdOutLine $progress `
            -Label ('terraform test {0}{1}' -f $relPrefix, $testDir) `
            -SuccessExitCode @(0, 1) `
            -IgnoreExitCode

        foreach ($rawLine in ([string]$result.StdOut -split "`r?`n")) {
            $runEvent = Read-AvmTerraformTestRunEvent -Line $rawLine
            if ($null -eq $runEvent) { continue }
            $runsTotal++
            if ($runEvent.Status -in @('fail', 'error')) { $runsFailed++ }
            elseif ($runEvent.Status -eq 'pass') { $runsPassed++ }
        }

        # terraform test exit codes: 0 = all runs passed, 1 = one or more failing
        # or errored runs. Anything else is a terraform-internal failure -> rethrow.
        if ($result.ExitCode -ne 0 -and $result.ExitCode -ne 1) {
            $stderr = if ($result.StdErr) { $result.StdErr.Trim() } else { '' }
            $tail = if ($stderr) { ": $stderr" } else { '.' }
            throw [AvmProcessException]::new(
                ('terraform test exited with code {0}{1}' -f $result.ExitCode, $tail))
        }
        if ($result.ExitCode -ne 0) { $anyFail = $true }

        foreach ($issue in (ConvertFrom-AvmTerraformTestJson -Payload ([string]$result.StdOut) -TestDir $testDir -RelPrefix $relPrefix)) {
            $issues.Add($issue)
        }
    }

    $status = if ($anyFail) { 'fail' } else { 'pass' }

    return [pscustomobject][ordered]@{
        Engine         = 'terraform'
        Tool           = ('{0}/{1}' -f $tool.Name, $tool.Version)
        ToolPath       = $tool.Path
        ToolSource     = $tool.Source
        Status         = $status
        FilesProcessed = $filesProcessed
        RunsTotal      = $runsTotal
        RunsPassed     = $runsPassed
        RunsFailed     = $runsFailed
        Issues         = $issues.ToArray()
    }
}

function Get-AvmTerraformTestTarget {
    <#
        .SYNOPSIS
            Enumerates runnable terraform test targets: the module root plus each
            immediate 'modules/*' subdirectory that ships at least one
            tests/<Tier>/*.tftest.hcl file.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string] $Root,

        [Parameter(Mandatory)]
        [string] $Tier
    )

    Set-StrictMode -Version 3.0
    $ErrorActionPreference = 'Stop'

    $candidates = New-Object System.Collections.Generic.List[object]
    $candidates.Add([pscustomobject]@{ Path = $Root; Rel = '' })

    $modulesDir = Join-Path $Root 'modules'
    if (Test-Path -LiteralPath $modulesDir -PathType Container) {
        $subModules = Get-ChildItem -LiteralPath $modulesDir -Directory -ErrorAction SilentlyContinue |
            Sort-Object -Property Name
        foreach ($sub in $subModules) {
            $candidates.Add([pscustomobject]@{ Path = $sub.FullName; Rel = ('modules/{0}' -f $sub.Name) })
        }
    }

    $targets = New-Object System.Collections.Generic.List[object]
    foreach ($candidate in $candidates) {
        $tierDir = Join-Path $candidate.Path (Join-Path 'tests' $Tier)
        if (-not (Test-Path -LiteralPath $tierDir -PathType Container)) { continue }
        $files = @(
            Get-ChildItem -LiteralPath $tierDir -Recurse -File -Filter '*.tftest.hcl' -ErrorAction SilentlyContinue
        )
        if ($files.Count -eq 0) { continue }
        $targets.Add([pscustomobject]@{
                Path  = $candidate.Path
                Rel   = $candidate.Rel
                Files = $files
            })
    }

    return $targets.ToArray()
}

function Invoke-AvmTerraformSetupHook {
    <#
        .SYNOPSIS
            Runs an optional setup.ps1 hook in an isolated pwsh subprocess.
            Returns $null when the hook is absent, otherwise the process result.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string] $PwshPath,

        [Parameter(Mandatory)]
        [string] $HookPath,

        [Parameter(Mandatory)]
        [string] $WorkingDirectory,

        [switch] $StreamOutput
    )

    Set-StrictMode -Version 3.0
    $ErrorActionPreference = 'Stop'

    if (-not (Test-Path -LiteralPath $HookPath -PathType Leaf)) {
        return $null
    }

    return Invoke-AvmProcess `
        -FilePath $PwshPath `
        -ArgumentList @('-NoProfile', '-NonInteractive', '-File', $HookPath) `
        -WorkingDirectory $WorkingDirectory `
        -StreamOutput:$StreamOutput `
        -IgnoreExitCode
}

function Read-AvmTerraformTestRunEvent {
    <#
        .SYNOPSIS
            Extracts the terminal 'test_run' event from a single line of the
            'terraform test -json' stream, or $null when the line is not one.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Line
    )

    Set-StrictMode -Version 3.0
    $ErrorActionPreference = 'Stop'

    $trimmed = $Line.Trim()
    if (-not $trimmed) { return $null }
    if (-not $trimmed.StartsWith('{')) { return $null }

    try { $obj = $trimmed | ConvertFrom-Json -ErrorAction Stop }
    catch { return $null }

    $type = if ($obj.PSObject.Properties.Name -contains 'type') { [string]$obj.type } else { '' }
    if ($type -ne 'test_run') { return $null }
    if (-not ($obj.PSObject.Properties.Name -contains 'test_run') -or -not $obj.test_run) { return $null }

    $run = $obj.test_run
    $status = if (($run.PSObject.Properties.Name -contains 'status') -and $run.status) { ([string]$run.status).ToLowerInvariant() } else { '' }
    if ($status -notin @('pass', 'fail', 'error', 'skip')) { return $null }

    return [pscustomobject]@{
        Name   = if (($run.PSObject.Properties.Name -contains 'run') -and $run.run) { [string]$run.run } else { '' }
        Path   = if (($run.PSObject.Properties.Name -contains 'path') -and $run.path) { [string]$run.path } else { '' }
        Status = $status
    }
}

function ConvertFrom-AvmTerraformTestJson {
    <#
        .SYNOPSIS
            Parses the newline-delimited JSON stream from 'terraform test -json'
            into the shared Issue shape. Submodule file paths are prefixed with
            RelPrefix (e.g. 'modules/<name>/').
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string] $Payload,

        [Parameter(Mandatory)]
        [string] $TestDir,

        [Parameter()]
        [string] $RelPrefix = ''
    )

    Set-StrictMode -Version 3.0
    $ErrorActionPreference = 'Stop'

    $issues = New-Object System.Collections.Generic.List[object]
    if ([string]::IsNullOrEmpty($Payload)) { return $issues.ToArray() }

    foreach ($rawLine in ($Payload -split "`n")) {
        $line = $rawLine.Trim()
        if (-not $line) { continue }
        if (-not $line.StartsWith('{')) { continue }

        try {
            $obj = $line | ConvertFrom-Json -ErrorAction Stop
        }
        catch {
            continue  # tolerate any non-JSON noise interleaved on the stream
        }

        $type = if ($obj.PSObject.Properties.Name -contains 'type') { [string]$obj.type } else { '' }

        if ($type -eq 'test_run' -and ($obj.PSObject.Properties.Name -contains 'test_run') -and $obj.test_run) {
            $run = $obj.test_run
            $runStatus = if ($run.PSObject.Properties.Name -contains 'status' -and $run.status) { [string]$run.status } else { '' }
            if ($runStatus.ToLowerInvariant() -in @('fail', 'error')) {
                $file = if ($run.PSObject.Properties.Name -contains 'path' -and $run.path) { [string]$run.path } else { $TestDir }
                $runName = if ($run.PSObject.Properties.Name -contains 'run' -and $run.run) { [string]$run.run } else { '' }
                $msg = if ($runName) { ("test run '{0}' {1}" -f $runName, $runStatus) } else { ('test run {0}' -f $runStatus) }
                $issues.Add([pscustomobject][ordered]@{
                        File     = ('{0}{1}' -f $RelPrefix, $file)
                        Line     = 0
                        Column   = 0
                        Severity = 'error'
                        Code     = ''
                        Message  = $msg
                    })
            }
            continue
        }

        if ($type -eq 'diagnostic' -and ($obj.PSObject.Properties.Name -contains 'diagnostic') -and $obj.diagnostic) {
            $diag = $obj.diagnostic
            $sev = if ($diag.PSObject.Properties.Name -contains 'severity' -and $diag.severity) { [string]$diag.severity } else { 'error' }
            if ($sev.ToLowerInvariant() -ne 'error') { continue }
            $summary = if ($diag.PSObject.Properties.Name -contains 'summary' -and $diag.summary) { [string]$diag.summary } else { '' }
            $detail = if ($diag.PSObject.Properties.Name -contains 'detail' -and $diag.detail) { [string]$diag.detail } else { '' }
            $msg = if ($detail) { "$summary - $detail" } else { $summary }
            $file = ''
            $lineNo = 0
            $col = 0
            if (($diag.PSObject.Properties.Name -contains 'range') -and $diag.range) {
                if ($diag.range.PSObject.Properties.Name -contains 'filename' -and $diag.range.filename) {
                    $file = [string]$diag.range.filename
                }
                if (($diag.range.PSObject.Properties.Name -contains 'start') -and $diag.range.start) {
                    if ($diag.range.start.PSObject.Properties.Name -contains 'line' -and $diag.range.start.line) {
                        $lineNo = [int]$diag.range.start.line
                    }
                    if ($diag.range.start.PSObject.Properties.Name -contains 'column' -and $diag.range.start.column) {
                        $col = [int]$diag.range.start.column
                    }
                }
            }
            $prefixed = if ($file) { '{0}{1}' -f $RelPrefix, $file } else { '' }
            $issues.Add([pscustomobject][ordered]@{
                    File     = $prefixed
                    Line     = $lineNo
                    Column   = $col
                    Severity = 'error'
                    Code     = ''
                    Message  = $msg
                })
            continue
        }
    }

    return $issues.ToArray()
}