tests/Invoke-DFToolCompanion.Tests.ps1

BeforeAll {
    . "$PSScriptRoot/TestSupport.ps1"
    foreach ($f in Get-DFTestModuleFile) { . $f }
}

Describe 'Invoke-DFToolCompanion' {
    BeforeEach {
        $script:TmpTools = Join-Path $TestDrive 'tools'
        New-Item -ItemType Directory -Force -Path $script:TmpTools | Out-Null
        Set-DFTestXdg

    }
    AfterEach {
        Remove-Variable -Name CompanionSawCurrentTool -Scope Global -ErrorAction Ignore
        Restore-DFTestXdg
    }

    It 'with -SetupOnly runs the setup step (seeds) and not the companion' {
        New-Item -ItemType Directory -Force (Join-Path $script:TmpTools 'so') | Out-Null
        Set-Content (Join-Path $script:TmpTools 'so' 'c.conf') 'seed' -NoNewline
        '$global:CompanionSawCurrentTool = "ran"' | Set-Content (Join-Path $script:TmpTools 'so.ps1')
        $tool = '{ "name": "so", "setup": { "seed": { "${XDG_CONFIG_HOME}/so/c.conf": "so/c.conf" } } }' | ConvertFrom-Json
        Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools -SetupOnly
        $global:CompanionSawCurrentTool | Should -BeNullOrEmpty
        Get-Content (Join-Path $Env:XDG_CONFIG_HOME 'so' 'c.conf') -Raw | Should -Be 'seed'
    }

    It 'dot-sources the regular companion, exposing $DFCurrentTool to it' {
        '$global:CompanionSawCurrentTool = $DFCurrentTool.name' |
            Set-Content (Join-Path $script:TmpTools 'companiontool.ps1')
        $tool = '{ "name": "companiontool" }' | ConvertFrom-Json
        Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools
        $global:CompanionSawCurrentTool | Should -Be 'companiontool'
    }

    It 'calls a won role hook from the companion with -Tool and -Role, and not a lost one' {
        @'
function Initialize-DFRoleWon { param($Tool, $Role) $global:HookSaw = "$($Tool.name):$Role" }
function Initialize-DFRoleLost { param($Tool, $Role) $global:HookSaw = 'lost' }
'@
 | Set-Content (Join-Path $script:TmpTools 'hooktool.ps1')
        $tool = '{ "name": "hooktool" }' | ConvertFrom-Json
        $won = [pscustomobject]@{ Role = 'won'; Hook = 'Initialize-DFRoleWon'; HookRequired = $true }
        Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools -WonRoles @($won)
        $global:HookSaw | Should -Be 'hooktool:won'
        Remove-Variable HookSaw -Scope Global
    }

    It 'is not thrown off by a companion or hook that reuses its local variable names' {
        @'
$Tool = $null; $ToolsPath = 'C:
owhere'; $WonRoles = @(); $SkipSetup = @('clobtool')
$companion = 'C:
owhere.ps1'; $hasCompanion = $false; $setupCompanion = 'C:
owhere.setup.ps1'
function Initialize-DFRoleOne { param($Tool, $Role) $global:ClobCalls += "one:$($Tool.name)"; $won = $null; $WonRoles = @(); $hook = $null }
function Initialize-DFRoleTwo { param($Tool, $Role) $global:ClobCalls += "two:$($Tool.name)" }
'@
 | Set-Content (Join-Path $script:TmpTools 'clobtool.ps1')
        # The setup script runs first (before the companion) and clobbers the same names.
        '$global:ClobCalls += "setup"; $Tool = $null; $ToolsPath = "C:
owhere"; $WonRoles = @()'
 |
            Set-Content (Join-Path $script:TmpTools 'clobtool.setup.ps1')
        $global:ClobCalls = @()
        $tool = '{ "name": "clobtool" }' | ConvertFrom-Json
        $won = @(
            [pscustomobject]@{ Role = 'one'; Hook = 'Initialize-DFRoleOne'; HookRequired = $true }
            [pscustomobject]@{ Role = 'two'; Hook = 'Initialize-DFRoleTwo'; HookRequired = $true }
        )
        try {
            Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools -WonRoles $won -WarningAction SilentlyContinue
            $global:ClobCalls | Should -Be @('setup', 'one:clobtool', 'two:clobtool')
        } finally { Remove-Variable ClobCalls -Scope Global -ErrorAction Ignore }
    }

    It 'leaves hook functions out of every scope after it returns' {
        'function Initialize-DFRoleWon { }' | Set-Content (Join-Path $script:TmpTools 'hooktool.ps1')
        $tool = '{ "name": "hooktool" }' | ConvertFrom-Json
        Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools -WonRoles @()
        Test-Path function:Initialize-DFRoleWon | Should -BeFalse
    }

    It 'does not leak $DFCurrentTool beyond Invoke-DFToolCompanion''s own scope' {
        # PowerShell tears down a function's local scope automatically on return,
        # so this holds regardless of whether Remove-Variable executes -- confirmed
        # empirically during review. This guards the invariant itself (no accidental
        # global/script leak), not the Remove-Variable calls, whose own effect isn't
        # independently observable from outside the function.
        'Set-Content (Join-Path $TestDrive "marker.txt") -Value "ran"' |
            Set-Content (Join-Path $script:TmpTools 'nodfcurrenttool.ps1')
        $tool = '{ "name": "nodfcurrenttool" }' | ConvertFrom-Json
        Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools
        Get-Variable -Name DFCurrentTool -Scope Global -ErrorAction Ignore | Should -BeNullOrEmpty
        Get-Variable -Name DFCurrentTool -ErrorAction Ignore | Should -BeNullOrEmpty
    }

    It 'does nothing when no companion .ps1 exists' {
        $tool = '{ "name": "nocompaniontool" }' | ConvertFrom-Json
        { Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools } | Should -Not -Throw
    }

    It 'runs the one-time setup companion and it can call Complete-DFToolSetup' {
        'Complete-DFToolSetup -Name $DFCurrentTool.name' |
            Set-Content (Join-Path $script:TmpTools 'setuptool.setup.ps1')
        $tool = '{ "name": "setuptool" }' | ConvertFrom-Json
        Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools
        (Get-DFToolSetupState).PSObject.Properties['setuptool'] | Should -Not -BeNullOrEmpty
    }

    It 'does not re-run one-time setup on a second call' {
        $runCountFile = Join-Path $TestDrive 'runcount.txt'
        # Double-quoted here-string interpolates $runCountFile now, but the backtick
        # keeps `$DFCurrentTool` literal -- it must only be evaluated later, when
        # Invoke-DFToolCompanion dot-sources this generated file.
        @"
'x' | Add-Content -Path '$runCountFile'
Complete-DFToolSetup -Name `$DFCurrentTool.name
"@
 | Set-Content (Join-Path $script:TmpTools 'oncetool.setup.ps1')
        $tool = '{ "name": "oncetool" }' | ConvertFrom-Json
        Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools
        Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools
        (Get-Content $runCountFile).Count | Should -Be 1
    }

    It 'skips one-time setup when the tool is in -SkipSetup' {
        'Complete-DFToolSetup -Name $DFCurrentTool.name' |
            Set-Content (Join-Path $script:TmpTools 'skippedtool.setup.ps1')
        $tool = '{ "name": "skippedtool" }' | ConvertFrom-Json
        Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools -SkipSetup @('skippedtool')
        (Get-DFToolSetupState).PSObject.Properties['skippedtool'] | Should -BeNullOrEmpty
    }

    It 'warns and continues when the one-time setup script throws' {
        'throw "boom"' | Set-Content (Join-Path $script:TmpTools 'throwtool.setup.ps1')
        $tool = '{ "name": "throwtool" }' | ConvertFrom-Json
        { Invoke-DFToolCompanion -Tool $tool -ToolsPath $script:TmpTools -WarningVariable warns 3>$null } |
            Should -Not -Throw
        (Get-DFToolSetupState).PSObject.Properties['throwtool'] | Should -BeNullOrEmpty
    }
}