Public/Tests/Invoke-TcsCommand.Tests.ps1

BeforeAll {
    $env:TCS_CONFIG_ROOT = Join-Path -Path $TestDrive -ChildPath 'config'
    $env:TCS_SKIP_UPDATE_CHECK = '1'
    $env:TCS_TELEMETRY_OPTOUT = '1'
    $ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
    Import-Module -Name (Join-Path -Path $ModuleRoot -ChildPath 'tcs.core.psd1') -Force

    # A consumer module whose exported commands use the wrapper
    $null = New-Module -Name tcs.wrapfake -ScriptBlock {
        function Get-Inner { [CmdletBinding()] param($X) Invoke-TcsCommand -ScriptBlock { "inner $X" } }
        function Get-Outer { [CmdletBinding()] param($X) Invoke-TcsCommand -ScriptBlock { 'outer'; Get-Inner -X $X } }
        function Get-NonTerminating { [CmdletBinding()] param() Invoke-TcsCommand -ScriptBlock { Write-Error 'not terminating'; 'after' } }
        function Get-Throws { [CmdletBinding()] param() Invoke-TcsCommand -ScriptBlock { throw [System.IO.IOException]::new('boom') } }
        function Get-Array { [CmdletBinding()] param() Invoke-TcsCommand -ScriptBlock { , @(1) } }
        function Get-Nothing { [CmdletBinding()] param() Invoke-TcsCommand -ScriptBlock { $null = 1 } }
        function Get-Handled {
            [CmdletBinding()]
            param()
            Invoke-TcsCommand -ScriptBlock {
                try { Get-Item -Path (Join-Path $TestDrive 'missing') -ErrorAction Stop } catch { 'handled' }
                $null = Get-Item -Path (Join-Path $TestDrive 'missing') -ErrorAction SilentlyContinue
                [System.Management.Automation.ErrorRecord]::new([System.Exception]::new('as data'), 'Data', 'NotSpecified', $null)
            }
        }
        function Get-NativeStderr {
            [CmdletBinding()]
            param()
            $shell = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
            Invoke-TcsCommand -ScriptBlock {
                # Windows PowerShell 5.1 turns redirected stderr into a terminating error under 'Stop'
                $ErrorActionPreference = 'Continue'
                & $shell -NoProfile -NonInteractive -Command "[Console]::Out.WriteLine('stdout line'); [Console]::Error.WriteLine('stderr line')"
                'done'
            }
        }
        function Set-Local { [CmdletBinding()] param() $value = 1; Invoke-TcsCommand -ScriptBlock { $value = 2 }; $value }
        function Test-ShouldProcess { [CmdletBinding(SupportsShouldProcess)] param() Invoke-TcsCommand -ScriptBlock { $PSCmdlet.ShouldProcess('target', 'action') } }
        function Get-Pipeline {
            [CmdletBinding()]
            param([Parameter(ValueFromPipeline)]$InputObject)
            begin { $telemetry = Start-TcsTelemetry; $count = 0 }
            process {
                Invoke-TcsCommand -Token $telemetry -ScriptBlock {
                    $count++
                    if ($InputObject -eq 'bad') { Write-Error "bad item" }
                    if ($InputObject -eq 'stop') { throw 'stopped' }
                    $null = Get-Inner -X $InputObject
                    $InputObject
                }
            }
            end { "count=$count"; Complete-TcsTelemetry -Token $telemetry }
        }
        Export-ModuleMember -Function *
    } | Import-Module -PassThru -Force
}

AfterAll {
    Remove-Module -Name tcs.wrapfake -Force -ErrorAction SilentlyContinue
    Remove-Module -Name tcs.core -Force -ErrorAction SilentlyContinue
}

Describe 'Invoke-TcsCommand' {
    BeforeEach {
        $env:TCS_TELEMETRY_OPTOUT = $null
        $env:TCS_TELEMETRY_URI = 'https://telemetry.example.com/ingest'
        Mock -ModuleName tcs.core Send-TelemetryPayload { }
    }

    AfterAll {
        $env:TCS_TELEMETRY_OPTOUT = '1'
        $env:TCS_TELEMETRY_URI = $null
    }

    It 'Reports a successful run with the calling command, module and version' {
        Get-Inner -X 1 | Should -Be 'inner 1'
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter {
            $body = $Body | ConvertFrom-Json
            $body.command_name -eq 'Get-Inner' -and $body.module_name -eq 'tcs.wrapfake' -and $body.version -eq '0.0' -and $body.success -eq $true
        }
    }

    It 'Uses the names that are passed' {
        $null = Invoke-TcsCommand -ScriptBlock { 1 } -CommandName 'Get-Named' -ModuleName 'tcs.named' -ModuleVersion '9.9.9'
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter {
            $body = $Body | ConvertFrom-Json
            $body.command_name -eq 'Get-Named' -and $body.module_name -eq 'tcs.named' -and $body.version -eq '9.9.9'
        }
    }

    It 'Reports only the outermost command when a command calls another command of the same module' {
        Get-Outer -X 1 | Should -Be @('outer', 'inner 1')
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter { ($Body | ConvertFrom-Json).command_name -eq 'Get-Outer' }
    }

    It 'Marks the run failed when the command writes a non-terminating error, and passes the error on' {
        # -ErrorAction Continue: the result must not depend on the caller's preference (GitHub's pwsh shell sets Stop)
        $output = Get-NonTerminating -ErrorAction Continue -ErrorVariable errs 2>$null
        $output | Should -Be 'after'
        $errs.Count | Should -Be 1
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter {
            $body = $Body | ConvertFrom-Json
            $body.success -eq $false -and $body.error_type -eq 'Microsoft.PowerShell.Commands.WriteErrorException'
        }
    }

    It 'Does not count handled or silenced errors, or error records written as output' {
        $output = Get-Handled 2>&1
        $output.Count | Should -Be 2
        $output[0] | Should -Be 'handled'
        $output[1] | Should -BeOfType [System.Management.Automation.ErrorRecord]
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter { ($Body | ConvertFrom-Json).success -eq $true }
    }

    It 'Passes native stderr lines to the error stream and does not count them as failures' {
        $output = @(Get-NativeStderr 2>$null)
        $output.Count | Should -Be 2
        $output | Should -Be @('stdout line', 'done')
        $errors = @(Get-NativeStderr 2>&1 | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] })
        ($errors | ForEach-Object { $_.ToString() }) -join ' ' | Should -Match 'stderr line'
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 2 -Exactly -ParameterFilter { ($Body | ConvertFrom-Json).success -eq $true }
    }

    It 'Passes errors on under the calling command''s error action, whatever the global preference' {
        $saved = $global:ErrorActionPreference
        try {
            $global:ErrorActionPreference = 'Stop'
            $output = Get-NonTerminating -ErrorAction Continue 2>$null
        }
        finally {
            $global:ErrorActionPreference = $saved
        }
        $output | Should -Be 'after'
    }

    It 'Rethrows terminating errors unchanged and marks the run failed' {
        $caught = $null
        try { Get-Throws } catch { $caught = $_ }
        $caught.Exception | Should -BeOfType [System.IO.IOException]
        $caught.Exception.Message | Should -Be 'boom'
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter {
            $body = $Body | ConvertFrom-Json
            $body.success -eq $false -and $body.error_type -eq 'System.IO.IOException' -and $Body -notmatch 'boom'
        }
    }

    It 'Honours -ErrorAction Stop on the calling command' {
        { Get-NonTerminating -ErrorAction Stop } | Should -Throw 'not terminating'
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter { ($Body | ConvertFrom-Json).success -eq $false }
    }

    It 'Writes only the script block output, without unrolling or adding anything' {
        $result = Get-Array
        , $result | Should -BeOfType [object[]]
        $result.Count | Should -Be 1
        @(Get-Nothing).Count | Should -Be 0
    }

    It 'Runs the script block in the scope of the calling command' {
        Set-Local | Should -Be 2
        Test-ShouldProcess -WhatIf | Should -BeFalse
    }

    It 'Never breaks the command when telemetry fails' {
        Mock -ModuleName tcs.core Invoke-TelemetryCollection { throw 'telemetry broken' }
        Get-Inner -X 2 2>&1 | Should -Be 'inner 2'
        Mock -ModuleName tcs.core New-TcsTelemetryToken { throw 'token broken' }
        Get-Inner -X 3 2>&1 | Should -Be 'inner 3'
    }

    It 'Sends nothing when telemetry is turned off' {
        $env:TCS_TELEMETRY_OPTOUT = '1'
        Get-Inner -X 1 | Should -Be 'inner 1'
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 0 -Exactly
    }
}

Describe 'Start-TcsTelemetry and Complete-TcsTelemetry' {
    BeforeEach {
        $env:TCS_TELEMETRY_OPTOUT = $null
        $env:TCS_TELEMETRY_URI = 'https://telemetry.example.com/ingest'
        Mock -ModuleName tcs.core Send-TelemetryPayload { }
    }

    AfterAll {
        $env:TCS_TELEMETRY_OPTOUT = '1'
        $env:TCS_TELEMETRY_URI = $null
    }

    It 'Sends one event for a whole pipeline run and skips nested commands' {
        'a', 'b' | Get-Pipeline | Should -Be @('a', 'b', 'count=2')
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter {
            $body = $Body | ConvertFrom-Json
            $body.command_name -eq 'Get-Pipeline' -and $body.success -eq $true
        }
    }

    It 'Marks a pipeline run failed when one item wrote an error' {
        $null = 'a', 'bad', 'c' | Get-Pipeline -ErrorAction Continue 2>$null
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter { ($Body | ConvertFrom-Json).success -eq $false }
    }

    It 'Completes the run as failed when a process block throws' {
        { 'a', 'stop', 'c' | Get-Pipeline } | Should -Throw 'stopped'
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter { ($Body | ConvertFrom-Json).success -eq $false }
    }

    It 'Completes the run when a downstream command stops the pipeline early' {
        'a', 'b', 'c' | Get-Pipeline | Select-Object -First 1 | Should -Be 'a'
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter {
            $body = $Body | ConvertFrom-Json
            $body.command_name -eq 'Get-Pipeline' -and $body.success -eq $true
        }
        # A run that never reached its end block does not hide later runs
        Get-Inner -X 1 | Should -Be 'inner 1'
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter { ($Body | ConvertFrom-Json).command_name -eq 'Get-Inner' }
    }

    It 'Returns a token with the given names and sends only once when completed twice' {
        $token = Start-TcsTelemetry -CommandName 'Get-Token' -ModuleName 'tcs.token' -ModuleVersion '1.0.0'
        $token.PSObject.TypeNames | Should -Contain 'Tcs.TelemetryToken'
        $token.IsOutermost | Should -BeTrue
        Complete-TcsTelemetry -Token $token -Failed
        $token | Complete-TcsTelemetry
        $token.Completed | Should -BeTrue
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter {
            $body = $Body | ConvertFrom-Json
            $body.command_name -eq 'Get-Token' -and $body.success -eq $false
        }
    }

    It 'Reports the type of an error passed to Complete-TcsTelemetry' {
        $token = Start-TcsTelemetry -ModuleName 'tcs.token'
        try { throw [System.TimeoutException]::new('slow') } catch { Complete-TcsTelemetry -Token $token -ErrorRecord $_ }
        Should -Invoke -ModuleName tcs.core Send-TelemetryPayload -Times 1 -Exactly -ParameterFilter { ($Body | ConvertFrom-Json).error_type -eq 'System.TimeoutException' }
    }

    It 'Writes nothing to the pipeline when completing' {
        $token = Start-TcsTelemetry -ModuleName 'tcs.token'
        @(Complete-TcsTelemetry -Token $token).Count | Should -Be 0
    }

    It 'Still returns a usable token when telemetry fails' {
        Mock -ModuleName tcs.core New-TcsTelemetryToken { throw 'broken' }
        $token = Start-TcsTelemetry -CommandName 'Get-X'
        $token.IsOutermost | Should -BeFalse
        { Complete-TcsTelemetry -Token $token } | Should -Not -Throw
    }
}