tests/Invoke-DFInstallPlan.Tests.ps1

BeforeAll {
    . "$PSScriptRoot/TestSupport.ps1"
    foreach ($f in Get-DFTestModuleFile) { . $f }
    function script:Rec([string]$Json) { ConvertTo-DFToolRecord ($Json | ConvertFrom-Json) }
}

Describe 'Expand-DFInstallArgv' {
    It 'expands {id} to every id, and other tokens from values' {
        Expand-DFInstallArgv -Template 'scoop', 'install', '{id}' -Values @{ ids = @('a', 'b') } | Should -Be @('scoop', 'install', 'a', 'b')
        Expand-DFInstallArgv -Template 'scoop', 'bucket', 'add', '{name}', '{url}' -Values @{ name = 'x'; url = 'https://u' } |
            Should -Be @('scoop', 'bucket', 'add', 'x', 'https://u')
    }
    It 'expands {id} inside a token once per id' {
        Expand-DFInstallArgv -Template 'mise', 'use', '-g', 'node@{id}' -Values @{ ids = @('lts') } | Should -Be @('mise', 'use', '-g', 'node@lts')
        Expand-DFInstallArgv -Template 'x', 'pkg@{id}' -Values @{ ids = @('a', 'b') } | Should -Be @('x', 'pkg@a', 'pkg@b')
    }
}

Describe 'Invoke-DFInstallPlan' {
    BeforeEach {
        Set-DFTestXdg; Reset-DFTestSession; Set-DFTestConfig $null
        $script:Calls = [System.Collections.Generic.List[string]]::new()
        $script:Fail = @()
        Mock Invoke-DFInstallCommand {
            $line = ($Argv -join ' ')
            if ($Elevate) { $line = "[elevated] $line" }
            $script:Calls.Add($line)
            [pscustomobject]@{ ExitCode = $(if (@($Argv | Where-Object { $_ -in $script:Fail }).Count) { 1 } else { 0 }); Output = 'out' }
        }
        Mock Update-DFPathFromRegistry { }
        Mock Register-DFTool { }
        Mock Test-DFElevated { $false }
        $script:Db = @{}
        foreach ($r in @(
            (Rec '{ "name": "scoop", "executable": "scoop.cmd", "installs": { "from": "scoop", "command": ["scoop","install","{id}"], "batch": true, "feeds": { "list": ["scoop","bucket","list"], "add": ["scoop","bucket","add","{name}","{url}"], "id": "{feed}/{id}" } } }'),
            (Rec '{ "name": "choco", "executable": "choco.exe", "installs": { "from": "choco", "command": ["choco","install","{id}","-y"], "batch": true, "elevate": true } }'),
            (Rec '{ "name": "gsudo", "executable": "gsudo.exe", "roles": { "elevator": {} } }'),
            (Rec '{ "name": "fnm", "executable": "fnm.exe", "packages": { "scoop": "fnm" }, "installs": { "from": "fnm", "command": ["fnm","install","{id}"], "reactivate": true } }'),
            (Rec '{ "name": "node", "executable": "node.exe", "packages": { "fnm": "lts" } }'),
            (Rec '{ "name": "glow", "executable": "glow.exe", "packages": { "scoop": "glow", "choco": "glow" } }'),
            (Rec '{ "name": "dotenv", "executable": "Dotenv", "type": "module", "packages": { "scoop": { "id": "ps-dotenv", "feed": { "name": "insomnia", "url": "https://u" } } } }')
        )) { $script:Db[$r.name] = $r }
        $script:Installed = @('scoop')
        $script:Avail = { param($m) $m.name -in $script:Installed }
        # After a successful install, the tool is "found".
        Mock Test-DFToolAvailable { $true }
    }
    AfterEach { Restore-DFTestXdg; Set-DFTestConfig $null }

    It 'runs one batch per manager, in stage order' {
        $plan = New-DFInstallPlan -Name fnm, glow, node -ToolDb $script:Db -IsAvailable $script:Avail
        $r = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        $script:Calls | Should -Be @('scoop install fnm glow', 'fnm install lts')
        ($r | Where-Object Result -eq 'Installed').Tool | Should -Be @('fnm', 'glow', 'node')
    }
    It 're-activates a manager that asks for it, and merges PATH, after its stage' {
        $plan = New-DFInstallPlan -Name fnm, node -ToolDb $script:Db -IsAvailable $script:Avail
        $null = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        Should -Invoke Update-DFPathFromRegistry -Times 2 -Exactly
        Should -Invoke Register-DFTool -Times 1 -ParameterFilter { $Name -contains 'fnm' }
    }
    It 'skips what depends on a failed stage, saying why' {
        $script:Fail = @('fnm')
        $plan = New-DFInstallPlan -Name fnm, node -ToolDb $script:Db -IsAvailable $script:Avail
        $r = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        ($r | Where-Object Tool -eq fnm).Result | Should -Be 'Failed'
        ($r | Where-Object Tool -eq node).Result | Should -Be 'Skipped'
        ($r | Where-Object Tool -eq node).Detail | Should -Match 'fnm failed'
        $script:Calls | Should -Not -Contain 'fnm install lts'
    }
    It 'adds a missing feed before installing from it, and not when it is already there' {
        $plan = New-DFInstallPlan -Name dotenv -ToolDb $script:Db -IsAvailable $script:Avail
        $null = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        $script:Calls | Should -Be @('scoop bucket list', 'scoop bucket add insomnia https://u', 'scoop install insomnia/ps-dotenv')

        $script:Calls.Clear()
        Mock Invoke-DFInstallCommand {
            $script:Calls.Add($Argv -join ' ')
            [pscustomobject]@{ ExitCode = 0; Output = $(if ($Argv -contains 'list') { "insomnia https://u`nmain x" } else { '' }) }
        }
        $null = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        $script:Calls | Should -Not -Contain 'scoop bucket add insomnia https://u'
    }
    It 'runs an elevated manager through gsudo when it is installed, and skips it otherwise' {
        Set-DFTestConfig @{ InstallVia = @{ glow = 'choco' } }
        $script:Installed = @('scoop', 'choco', 'gsudo')
        $plan = New-DFInstallPlan -Name glow -ToolDb $script:Db -IsAvailable $script:Avail
        $null = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        $script:Calls | Should -Be @('[elevated] choco install glow -y')

        # Without an elevator, choco can't run, so glow comes from its next source.
        $script:Calls.Clear(); $script:Installed = @('scoop', 'choco')
        $plan = New-DFInstallPlan -Name glow -ToolDb $script:Db -IsAvailable $script:Avail
        $null = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        $script:Calls | Should -Be @('scoop install glow')
    }
    It 'counts the tools of a failed batch that did install (one bad id doesn''t fail the rest)' {
        $script:Fail = @('glow')
        Mock Test-DFToolAvailable { $Executable -ne 'glow.exe' }
        $plan = New-DFInstallPlan -Name fnm, glow, node -ToolDb $script:Db -IsAvailable $script:Avail
        $r = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        ($r | Where-Object Tool -eq fnm).Result | Should -Be 'Installed'
        ($r | Where-Object Tool -eq glow).Result | Should -Be 'Failed'
        $script:Calls | Should -Contain 'fnm install lts'
    }
    It 'says why a dependent was skipped in words' {
        Mock Test-DFToolAvailable { $Executable -ne 'fnm.exe' }
        $plan = New-DFInstallPlan -Name fnm, node -ToolDb $script:Db -IsAvailable $script:Avail
        $r = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        ($r | Where-Object Tool -eq node).Detail | Should -Be "skipped: fnm isn't found yet"
    }
    It 'reports a tool that installed but still isn''t found' {
        Mock Test-DFToolAvailable { $false }
        $plan = New-DFInstallPlan -Name glow -ToolDb $script:Db -IsAvailable $script:Avail
        $r = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $script:Avail
        ($r | Where-Object Tool -eq glow).Result | Should -Be 'NotFound'
        ($r | Where-Object Tool -eq glow).Detail | Should -Match 'open a new shell'
    }
}

Describe 'Update-DFPathFromRegistry' {
    It 'appends only entries the session lacks, and never reorders' {
        $saved = $Env:Path
        try {
            $Env:Path = 'C:\a;C:\b'
            Mock Get-DFRegistryPath { 'C:\b;C:\new' }
            Update-DFPathFromRegistry
            $Env:Path | Should -Be 'C:\a;C:\b;C:\new'
        } finally { $Env:Path = $saved }
    }
}

Describe 'Invoke-DFInstallCommand' {
    It 'reports success for an in-process .ps1 shim even when an earlier command left a failure code' {
        $bin = Join-Path $TestDrive "shim-$([guid]::NewGuid().ToString('N').Substring(0,6))"
        New-Item -ItemType Directory $bin | Out-Null
        'param() "ok"' | Set-Content (Join-Path $bin 'dffakemgr.ps1')
        $saved = $Env:Path
        try {
            $Env:Path = "$bin;$Env:Path"
            $global:LASTEXITCODE = 1
            (Invoke-DFInstallCommand -Manager ([pscustomobject]@{ name = 'dffakemgr' }) -Argv 'dffakemgr', 'install', 'x').ExitCode | Should -Be 0
        } finally { $Env:Path = $saved }
    }
}

Describe 'Test-DFInteractiveHost' {
    It 'is false when PowerShell was started with -NonInteractive (or -noni)' {
        Test-DFInteractiveHost -CommandLine @('pwsh', '-NonInteractive', '-File', 'setup.ps1') | Should -BeFalse
        Test-DFInteractiveHost -CommandLine @('pwsh', '-noni', '-c', 'x') | Should -BeFalse
    }
}

Describe 'Invoke-DFInstallPlan with a two-block manager' {
    BeforeEach {
        Set-DFTestXdg; Reset-DFTestSession; Set-DFTestConfig $null
        $script:Calls = [System.Collections.Generic.List[string]]::new()
        Mock Invoke-DFInstallCommand { $script:Calls.Add($Argv -join ' '); [pscustomobject]@{ ExitCode = 0; Output = '' } }
        Mock Update-DFPathFromRegistry { }
        Mock Register-DFTool { }
        Mock Test-DFElevated { $false }
        Mock Test-DFToolAvailable { $true }
        $script:Db = @{}
        foreach ($r in @(
            (Rec '{ "name": "uv", "executable": "uv.exe", "installs": [ { "from": "pypi", "command": ["uv","tool","install","{id}"] }, { "from": "uv", "command": ["uv","python","install","--default","{id}"] } ] }'),
            (Rec '{ "name": "ruff", "executable": "ruff.exe", "packages": { "pypi": "ruff" } }'),
            (Rec '{ "name": "python", "executable": "python.exe", "packages": { "uv": "3" } }')
        )) { $script:Db[$r.name] = $r }
    }
    AfterEach { Restore-DFTestXdg; Set-DFTestConfig $null }
    It 'runs each tool with the block for its source, in separate batches' {
        $avail = { param($m) $m.name -eq 'uv' }
        $plan = New-DFInstallPlan -Name python, ruff -ToolDb $script:Db -IsAvailable $avail
        @($plan.Stages[0].Batches).Count | Should -Be 2
        $null = Invoke-DFInstallPlan -Plan $plan -ToolDb $script:Db -IsAvailable $avail
        # Same-stage batches are independent, so their order doesn't matter.
        @($script:Calls | Sort-Object) | Should -Be @('uv python install --default 3', 'uv tool install ruff')
    }
}