tests/scoop.Tests.ps1
|
BeforeAll { $script:CompanionPath = Join-Path $PSScriptRoot '../Tools/scoop.ps1' } Describe 'scoop companion' { AfterEach { Remove-Item 'function:global:scoop' -ErrorAction Ignore Remove-Item 'function:global:scoop-search' -ErrorAction Ignore Remove-Item 'function:global:git' -ErrorAction Ignore } # NOTE: This test runs FIRST on purpose. Defining then removing a global # `scoop-search` function (as the two tests below do) leaves a stale # Function-typed entry in PowerShell's command-resolution cache that survives # PATH clearing — so the absent-scoop-search scenario must be exercised before # any test introduces that ghost. Pester runs It blocks in file order. It 'does nothing and does not throw when scoop-search is absent' { function global:git { } # Make scoop-search unresolvable even on a machine where it is really on # PATH: it is an Application (a shim), so an empty PATH hides it. (Real exe # lookups are not cached across a PATH change; only function ghosts are.) # Restore PATH afterward regardless of outcome. $savedPath = $env:PATH try { $env:PATH = '' { . $script:CompanionPath } | Should -Not -Throw Get-Command scoop -CommandType Function -ErrorAction Ignore | Should -BeNullOrEmpty } finally { $env:PATH = $savedPath } } It 'installs the scoop-search hook as a GLOBAL function that survives Register-DFTool''s scope' { # Regression: scoop-search --hook emits `function scoop { ... }` with no # scope modifier. Register-DFTool dot-sources this companion INSIDE its own # function scope, so an unqualified definition would be discarded on return # and never reach the prompt. Reproduce that scope with an inner function, # then assert the hook survives globally afterward. # Stand-in for the real `scoop-search` shim: emits the exact hook text # (with an unqualified `function scoop`, as scoop-search does) and ignores # the --hook argument. The companion resolves this via Get-Command and & . function global:scoop-search { 'function scoop { if ($args[0] -eq "search") { scoop-search.exe @($args | Select-Object -Skip 1) } else { scoop.ps1 @args } }' } function global:git { } # satisfy the git-present check; no warning path function Invoke-LikeRegisterDFTool { . $script:CompanionPath # Inside the dot-sourcing scope the function must resolve... (Get-Command scoop -CommandType Function -ErrorAction Ignore) | Should -Not -BeNullOrEmpty } Invoke-LikeRegisterDFTool # ...and it must STILL resolve once that scope has returned. $fn = Get-Command scoop -CommandType Function -ErrorAction Ignore $fn | Should -Not -BeNullOrEmpty $fn.Definition | Should -Match 'scoop-search' } It 'leaves an already-global hook untouched (forward-compat with upstream global:)' { # Guard: if scoop-search is changed to emit `function global:scoop { ... }` # (a proposed upstream fix), the companion must not rewrite it — no double # `global:global:` mangling — and the hook must still install globally. function global:scoop-search { 'function global:scoop { if ($args[0] -eq "search") { scoop-search.exe @($args | Select-Object -Skip 1) } else { scoop.ps1 @args } }' } function global:git { } { . $script:CompanionPath } | Should -Not -Throw $fn = Get-Command scoop -CommandType Function -ErrorAction Ignore $fn | Should -Not -BeNullOrEmpty # No mangled scope token leaked into the definition. $fn.Definition | Should -Not -Match 'global:' } } # NOTE: This Describe MUST come after 'scoop companion' above. Its BeforeEach # dot-sources the companion with a scoop-search stub present, which installs a # global `scoop` function (via the hook) — and defining/removing that global # leaves a command-resolution-cache ghost that survives PATH clearing. The # 'does nothing when scoop-search is absent' test above depends on running # before any such ghost exists, so these picker tests are ordered last. Describe 'scoop pickers' { BeforeAll { . "$PSScriptRoot/../Private/Invoke-DFFzf.ps1" . "$PSScriptRoot/../Public/Invoke-DFPicker.ps1" Import-Module Scoop -ErrorAction SilentlyContinue # so *-ScoopApp exist to mock function script:SApp ($Name, $Version, $Source) { [pscustomobject]@{ Name = $Name; Version = $Version; Source = $Source } } } BeforeEach { # scoop-search stand-in: emits hook text for --hook (consumed when the # companion loads), canned search results otherwise. function global:scoop-search { if ($args[0] -eq '--hook') { return 'function scoop { }' } "'main' bucket:" " ripgrep (15.2.0) --> includes 'rg.exe'" " rga (0.10.9)" } function global:git { } # silence the git-absent warning . $script:CompanionPath } AfterEach { 'Select-ScoopPackage', 'Remove-ScoopPackage', 'Invoke-ScoopUpdate', 'Assert-DFScoopModule', 'scoop-search', 'git', 'scoop' | ForEach-Object { Remove-Item "function:global:$_" -ErrorAction Ignore } 'sins', 'srm', 'sup' | ForEach-Object { Remove-Item "alias:global:$_" -ErrorAction Ignore } Remove-PSReadLineKeyHandler -Chord 'Ctrl+g,s' -ErrorAction Ignore } It 'defines sins/srm/sup functions and aliases globally' { (Get-Command Select-ScoopPackage -CommandType Function) | Should -Not -BeNullOrEmpty (Get-Alias sins).Definition | Should -Be 'Select-ScoopPackage' (Get-Alias srm).Definition | Should -Be 'Remove-ScoopPackage' (Get-Alias sup).Definition | Should -Be 'Invoke-ScoopUpdate' } It 'binds Ctrl+G,S to the install-command prefill when PSReadLine is present' { (Get-PSReadLineKeyHandler | Where-Object { $_.Key -eq 'Ctrl+g,s' }) | Should -Not -BeNullOrEmpty } It 'installs the pickers as GLOBAL functions that survive Register-DFTool''s scope' { function Invoke-LikeRegisterDFTool { . $script:CompanionPath (Get-Command sins -ErrorAction Ignore) | Should -Not -BeNullOrEmpty } Invoke-LikeRegisterDFTool (Get-Command Select-ScoopPackage -CommandType Function -ErrorAction Ignore) | Should -Not -BeNullOrEmpty } Context 'search / install (sins)' { It 'parses scoop-search output into "<display>`t<name>" lines (bucket header skipped)' { $script:fed = $null Mock Invoke-DFFzf { $script:fed = $InputItems; '' ; @($InputItems)[0] } Select-ScoopPackage -Query 'rip' | Out-Null @($script:fed).Count | Should -Be 2 @($script:fed)[0] | Should -Match "`tripgrep$" } It 'returns the install command on Enter' { Mock Invoke-DFFzf { '' ; @($InputItems)[0] } Select-ScoopPackage -Query 'rip' | Should -Be 'scoop install ripgrep' } It 'calls Install-ScoopApp on Alt-R' { Mock Invoke-DFFzf { 'alt-r' ; @($InputItems)[0] } Mock Install-ScoopApp { } Select-ScoopPackage -Query 'rip' | Out-Null Should -Invoke Install-ScoopApp -ParameterFilter { $Name -eq 'ripgrep' } } } Context 'uninstall (srm)' { BeforeEach { Mock Get-ScoopApp { SApp '7zip' '26.02' 'main' } } It 'calls Uninstall-ScoopApp on Enter' { Mock Invoke-DFFzf { '' ; @($InputItems)[0] } Mock Uninstall-ScoopApp { } Remove-ScoopPackage | Out-Null Should -Invoke Uninstall-ScoopApp -ParameterFilter { $Name -eq '7zip' } } It 'returns the uninstall command on Alt-C' { Mock Invoke-DFFzf { 'alt-c' ; @($InputItems)[0] } Remove-ScoopPackage | Should -Be 'scoop uninstall 7zip' } } Context 'update (sup)' { BeforeEach { Mock Get-ScoopApp { SApp 'aria2' '1.37.0' 'main'; SApp 'bat' '0.26.1' 'main' } } It 'updates each selected app on Enter (multi-select)' { Mock Invoke-DFFzf { '' ; $InputItems } Mock Update-ScoopApp { } Invoke-ScoopUpdate | Out-Null Should -Invoke Update-ScoopApp -Times 2 } It 'runs scoop update * on Alt-A' { Mock Invoke-DFFzf { 'alt-a' } Mock scoop { } Invoke-ScoopUpdate | Out-Null Should -Invoke scoop -ParameterFilter { $args -contains 'update' -and $args -contains '*' } } } Context 'module guard' { It 'returns without invoking the picker when the Scoop module is absent' { Mock Assert-DFScoopModule { $false } Mock Invoke-DFPicker { throw 'picker should not run' } { Select-ScoopPackage -Query 'x' } | Should -Not -Throw Should -Invoke Invoke-DFPicker -Times 0 } } } |