diff --git a/DevSetup/Private/Commands/Uninstall-DevSetupEnv.ps1 b/DevSetup/Private/Commands/Uninstall-DevSetupEnv.ps1 index f08b3f3..a149d91 100644 --- a/DevSetup/Private/Commands/Uninstall-DevSetupEnv.ps1 +++ b/DevSetup/Private/Commands/Uninstall-DevSetupEnv.ps1 @@ -120,7 +120,7 @@ Function Uninstall-DevSetupEnv { Invoke-ChocolateyPackageUninstall -YamlData $YamlData -DryRun:$DryRun | Out-Null # Uninstall Scoop package dependencies - Invoke-ScoopComponentUninstall -YamlData $YamlData | Out-Null + Invoke-ScoopComponentUninstall -YamlData $YamlData -DryRun:$DryRun | Out-Null } else { # Uninstall Homebrew package dependencies Invoke-HomebrewComponentsUninstall -YamlData $YamlData -DryRun:$DryRun | Out-Null diff --git a/DevSetup/Private/Enums/TaskState.ps1 b/DevSetup/Private/Enums/TaskState.ps1 deleted file mode 100644 index f3a787a..0000000 --- a/DevSetup/Private/Enums/TaskState.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -Add-Type -ErrorAction SilentlyContinue -IgnoreWarnings -Language CSharp -TypeDefinition @" - [System.FlagsAttribute] - public enum TaskState { - Unknown = 0, - Pass = 1 << 0, - Warn = 1 << 1, - Fail = 1 << 2, - } -"@ \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.Tests.ps1 new file mode 100644 index 0000000..96665bd --- /dev/null +++ b/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.Tests.ps1 @@ -0,0 +1,394 @@ +BeforeAll { + . $PSScriptRoot\Get-PowershellModuleScopeMap.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1 +} + +Describe "Get-PowershellModuleScopeMap" { + + Context "When running on Windows" { + BeforeEach { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $true + } + } + + It "Should use USERPROFILE as search path and correctly map CurrentUser scope" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + + $result | Should -HaveCount 2 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath + $result[1].Scope | Should -Be "AllUsers" + } + + It "Should handle PowerShell 7 module paths on Windows" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "PowerShell" "Modules" + $ps7Path = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + $ps5Path = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$ps7Path$([System.IO.Path]::PathSeparator)$ps5Path" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "PowerShell" "Modules" + $expectedPs7Path = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + $expectedPs5Path = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedPs7Path + $result[1].Scope | Should -Be "AllUsers" + $result[2].Path | Should -Be $expectedPs5Path + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle mixed user profile paths correctly" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { + $oneDrivePath = Join-Path $TestDrive "Users" "TestUser" "OneDrive" "Documents" "PowerShell" "Modules" + $regularPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + return "$oneDrivePath$([System.IO.Path]::PathSeparator)$regularPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedOneDrivePath = Join-Path $TestDrive "Users" "TestUser" "OneDrive" "Documents" "PowerShell" "Modules" + $expectedRegularPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedOneDrivePath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedRegularPath + $result[1].Scope | Should -Be "CurrentUser" + $result[2].Path | Should -Be $expectedSystemPath + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle empty PSModulePath" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { return "" } + } + } + + $result = Get-PowershellModuleScopeMap + + # When PSModulePath is empty, filtering removes empty entries + $result | Should -HaveCount 0 + } + } + + Context "When running on Linux" { + BeforeEach { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $false + } + } + + It "Should use HOME as search path and correctly map CurrentUser scope" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "home" "testuser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "home" "testuser" ".local" "share" "powershell" "Modules" + $systemPath1 = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + $systemPath2 = Join-Path $TestDrive "opt" "microsoft" "powershell" "7" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath1$([System.IO.Path]::PathSeparator)$systemPath2" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "home" "testuser" ".local" "share" "powershell" "Modules" + $expectedSystemPath1 = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + $expectedSystemPath2 = Join-Path $TestDrive "opt" "microsoft" "powershell" "7" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath1 + $result[1].Scope | Should -Be "AllUsers" + $result[2].Path | Should -Be $expectedSystemPath2 + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle custom user paths on Linux" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "home" "testuser") } + "PSModulePath" { + $customPath = Join-Path $TestDrive "home" "testuser" "custom" "powershell" "modules" + $userPath = Join-Path $TestDrive "home" "testuser" ".local" "share" "powershell" "Modules" + $systemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + return "$customPath$([System.IO.Path]::PathSeparator)$userPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedCustomPath = Join-Path $TestDrive "home" "testuser" "custom" "powershell" "modules" + $expectedUserPath = Join-Path $TestDrive "home" "testuser" ".local" "share" "powershell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedCustomPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedUserPath + $result[1].Scope | Should -Be "CurrentUser" + $result[2].Path | Should -Be $expectedSystemPath + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle root user paths correctly" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "root") } + "PSModulePath" { + $rootPath = Join-Path $TestDrive "root" ".local" "share" "powershell" "Modules" + $systemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + return "$rootPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedRootPath = Join-Path $TestDrive "root" ".local" "share" "powershell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + + $result | Should -HaveCount 2 + $result[0].Path | Should -Be $expectedRootPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath + $result[1].Scope | Should -Be "AllUsers" + } + } + + Context "When running on macOS" { + BeforeEach { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $false + } + } + + It "Should use HOME as search path and correctly map CurrentUser scope" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "Users" "testuser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "testuser" ".local" "share" "powershell" "Modules" + $systemPath1 = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + $systemPath2 = Join-Path $TestDrive "opt" "microsoft" "powershell" "7" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath1$([System.IO.Path]::PathSeparator)$systemPath2" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "testuser" ".local" "share" "powershell" "Modules" + $expectedSystemPath1 = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + $expectedSystemPath2 = Join-Path $TestDrive "opt" "microsoft" "powershell" "7" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath1 + $result[1].Scope | Should -Be "AllUsers" + $result[2].Path | Should -Be $expectedSystemPath2 + $result[2].Scope | Should -Be "AllUsers" + } + + It "Should handle Homebrew installed PowerShell paths" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "HOME" { return (Join-Path $TestDrive "Users" "testuser") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "testuser" ".local" "share" "powershell" "Modules" + $homebrewPath = Join-Path $TestDrive "opt" "homebrew" "share" "powershell" "Modules" + $systemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$homebrewPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "testuser" ".local" "share" "powershell" "Modules" + $expectedHomebrewPath = Join-Path $TestDrive "opt" "homebrew" "share" "powershell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "usr" "local" "share" "powershell" "Modules" + + $result | Should -HaveCount 3 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedHomebrewPath + $result[1].Scope | Should -Be "AllUsers" + $result[2].Path | Should -Be $expectedSystemPath + $result[2].Scope | Should -Be "AllUsers" + } + } + + Context "Edge cases and special characters" { + BeforeEach { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $true + } + } + + It "Should handle paths with special characters" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "Test User (Admin)") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "Test User (Admin)" "Documents" "PowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "Test User (Admin)" "Documents" "PowerShell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + + $result | Should -HaveCount 2 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath + $result[1].Scope | Should -Be "AllUsers" + } + + It "Should handle paths with regex special characters" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "Test.User[1]") } + "PSModulePath" { + $userPath = Join-Path $TestDrive "Users" "Test.User[1]" "Documents" "PowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + return "$userPath$([System.IO.Path]::PathSeparator)$systemPath" + } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "Test.User[1]" "Documents" "PowerShell" "Modules" + $expectedSystemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + + $result | Should -HaveCount 2 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + $result[1].Path | Should -Be $expectedSystemPath + $result[1].Scope | Should -Be "AllUsers" + } + + It "Should handle single path entry" { + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { return (Join-Path $TestDrive "Users" "TestUser" "Documents" "PowerShell" "Modules") } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedUserPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "PowerShell" "Modules" + + $result | Should -HaveCount 1 + $result[0].Path | Should -Be $expectedUserPath + $result[0].Scope | Should -Be "CurrentUser" + } + } + + Context "Error scenarios" { + It "Should handle null PSModulePath gracefully" { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $true + } + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return (Join-Path $TestDrive "Users" "TestUser") } + "PSModulePath" { return $null } + } + } + + $result = Get-PowershellModuleScopeMap + + # When PSModulePath is null, filtering removes null/empty entries + $result | Should -HaveCount 0 + } + + It "Should handle null USERPROFILE/HOME gracefully" { + Mock Test-OperatingSystem { + param([switch]$Windows) + return $true + } + Mock Get-EnvironmentVariable { + param($Name) + switch ($Name) { + "USERPROFILE" { return $null } + "PSModulePath" { return (Join-Path $TestDrive "Program Files" "PowerShell" "Modules") } + } + } + + $result = Get-PowershellModuleScopeMap + + $expectedSystemPath = Join-Path $TestDrive "Program Files" "PowerShell" "Modules" + + $result | Should -HaveCount 1 + $result[0].Path | Should -Be $expectedSystemPath + $result[0].Scope | Should -Be "AllUsers" + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.ps1 b/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.ps1 new file mode 100644 index 0000000..c4b2c5a --- /dev/null +++ b/DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.ps1 @@ -0,0 +1,20 @@ +Function Get-PowershellModuleScopeMap { + [CmdletBinding()] + [OutputType([array])] + Param() + + if((Test-OperatingSystem -Windows)) { + $SearchPath = (Get-EnvironmentVariable USERPROFILE) + } else { + $SearchPath = (Get-EnvironmentVariable HOME) + } + + $InstallPaths = @( + (Get-EnvironmentVariable PSModulePath) -split ([System.IO.Path]::PathSeparator) | Where-Object { $_ -ne $null -and $_.Trim() -ne "" } | ForEach-Object { + $scope = if($SearchPath -and ($_ -match [regex]::Escape($SearchPath))) { "CurrentUser" } else { "AllUsers" } + [PSCustomObject]@{ Path = $_; Scope = $scope } + } + ) + + return $InstallPaths +} \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Install-PowershellModule.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Install-PowershellModule.Tests.ps1 index aaeef8b..f660814 100644 --- a/DevSetup/Private/Providers/Powershell/Install-PowershellModule.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Install-PowershellModule.Tests.ps1 @@ -18,6 +18,23 @@ Describe "Install-PowershellModule" { } } + Context "When Test-RunningAsAdmin throws an exception" { + It "Should return false" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + $result = Install-PowershellModule -ModuleName "Az" -Scope "AllUsers" + $result | Should -Be $false + } + } + + Context "When Test-PowershellModuleInstalled throws an exception" { + It "Should return false" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-PowershellModuleInstalled { throw "Module test failed" } + $result = Install-PowershellModule -ModuleName "Az" + $result | Should -Be $false + } + } + Context "When module is already installed with correct version and scope" { It "Should return true and not call Uninstall-PowershellModule or Install-Module" { Mock Test-RunningAsAdmin { return $true } @@ -133,4 +150,38 @@ Describe "Install-PowershellModule" { $installParams.RequiredVersion | Should -Be "9.0.1" } } + + Context "When WhatIf is specified" { + It "Should return true and not install the module" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-PowershellModuleInstalled { + return [InstalledState]::NotInstalled + } + Mock Install-Module { throw "Should not be called" } + $result = Install-PowershellModule -ModuleName "Az" -WhatIf + $result | Should -Be $true + } + } + + Context "When module is installed with CurrentUser scope by default" { + It "Should use CurrentUser scope when no scope is specified" { + Mock Test-RunningAsAdmin { return $true } + Mock Test-PowershellModuleInstalled { + return [InstalledState]::NotInstalled + } + Mock Install-Module -MockWith { + param( + [string]$Name, + [string]$Scope + ) + $script:installParams = @{ + ModuleName = $Name + Scope = $Scope + } + } + $result = Install-PowershellModule -ModuleName "Az" + $result | Should -Be $true + $installParams.Scope | Should -Be "CurrentUser" + } + } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Install-PowershellModule.ps1 b/DevSetup/Private/Providers/Powershell/Install-PowershellModule.ps1 index f4c717e..f7f2937 100644 --- a/DevSetup/Private/Providers/Powershell/Install-PowershellModule.ps1 +++ b/DevSetup/Private/Providers/Powershell/Install-PowershellModule.ps1 @@ -97,54 +97,71 @@ Function Install-PowershellModule { [ValidateSet('CurrentUser', 'AllUsers')] [String] $Scope = 'CurrentUser' ) - + try { # Check if running as administrator only when installing for all users if ($Scope -eq 'AllUsers' -and (-not (Test-RunningAsAdmin))) { - throw "PowerShell module installation to AllUsers scope requires administrator privileges. Please run as administrator or use CurrentUser scope." - } - - $installParams = @{ - Name = $ModuleName - Force = $Force - Scope = $Scope - AllowClobber = $AllowClobber - SkipPublisherCheck = $true + Write-StatusMessage "PowerShell module installation to AllUsers scope requires administrator privileges. Please run as administrator or use CurrentUser scope." -Verbosity Error + return $false } + } catch { + Write-StatusMessage "Failed to validate administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + $installParams = @{ + Name = $ModuleName + Force = $Force + Scope = $Scope + AllowClobber = $AllowClobber + SkipPublisherCheck = $true + } - $testParams = @{ - ModuleName = $ModuleName - Scope = $Scope - } + $testParams = @{ + ModuleName = $ModuleName + Scope = $Scope + } - if($PSBoundParameters.ContainsKey('Version')) { - $testParams.Version = $Version - $installParams.RequiredVersion = $Version - } + if($PSBoundParameters.ContainsKey('Version')) { + $testParams.Version = $Version + $installParams.RequiredVersion = $Version + } + try { $testResult = Test-PowershellModuleInstalled @testParams + } catch { + Write-StatusMessage "Failed to test if PowerShell module is installed: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - if($testResult.HasFlag([InstalledState]::Pass)) { - return $true - } + if($testResult.HasFlag([InstalledState]::Pass)) { + return $true + } - if($testResult.HasFlag([InstalledState]::Installed)) { - try { - Uninstall-PowershellModule -ModuleName $ModuleName -WhatIf:$WhatIf - } catch { - # Uninstall might have failed, we keep going anyways - Write-StatusMessage "Failed to uninstall existing module '$ModuleName': $_" -Verbosity Error - Write-StatusMessage $_.ScriptStackTrace -Verbosity Error - } + if($testResult.HasFlag([InstalledState]::Installed)) { + try { + Uninstall-PowershellModule -ModuleName $ModuleName -WhatIf:$WhatIf + } catch { + # Uninstall might have failed, we keep going anyways + Write-StatusMessage "Failed to uninstall existing module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error } + } - # Install the PowerShell module - if ($PSCmdlet.ShouldProcess($ModuleName, "Install-Module")) { + # Install the PowerShell module + if ($PSCmdlet.ShouldProcess($ModuleName, "Install-Module")) { + try { Install-Module @installParams + } catch { + Write-StatusMessage "Failed to install PowerShell module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false } + } else { + Write-StatusMessage "Installation of module '$ModuleName' was skipped due to ShouldProcess." -Verbosity Warning return $true } - catch { - return $false - } + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 index 191c11c..911deac 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 @@ -6,14 +6,31 @@ BeforeAll { . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Update-DevSetupEnvFile.ps1") . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Get-DevSetupManifest.ps1") . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") + . (Join-Path $PSScriptRoot "Get-PowershellModuleScopeMap.ps1") + Mock Test-RunningAsAdmin { $true } - Mock Get-InstalledModule { @( - @{ Name = "ModuleA"; Version = [version]"1.0.0" }, - @{ Name = "ModuleB"; Version = [version]"2.0.0" } - ) } + Mock Get-InstalledModule { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + @( + @{ Name = "ModuleA"; Version = [version]"1.0.0"; InstalledLocation = (Join-Path $userPath "ModuleA") }, + @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") } + ) + } Mock Get-DevSetupManifest { @{ RequiredModules = @("ModuleA") } } - Mock Get-Module { param($Name) @{ Name = $Name; ModuleBase = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\$Name"; Version = [version]"1.0.0" } } - Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @() } } } } } + Mock Get-PowershellModuleScopeMap { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + @( + @{ Path = $userPath; Scope = "CurrentUser" }, + @{ Path = $systemPath; Scope = "AllUsers" } + ) + } + Mock Get-Module { + param($Name) + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + @{ Name = $Name; ModuleBase = (Join-Path $userPath $Name); Version = [version]"1.0.0" } + } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(); scope = "CurrentUser" } } } } } Mock Update-DevSetupEnvFile { } Mock Write-Host { } Mock Write-Warning { } @@ -34,6 +51,51 @@ Describe "Invoke-PowershellModulesExport" { } } + Context "When Test-RunningAsAdmin throws an exception" { + It "Should return false and log error" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to validate administrator privileges" -and $Verbosity -eq "Error" } + } + } + + Context "When Get-DevSetupManifest throws an exception" { + It "Should return false and log error" { + Mock Get-DevSetupManifest { throw "Manifest read failed" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to read DevSetup manifest" -and $Verbosity -eq "Error" } + } + } + + Context "When Get-PowershellModuleScopeMap throws an exception" { + It "Should return false and log error" { + Mock Get-PowershellModuleScopeMap { throw "Scope map failed" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to get PowerShell module scope map" -and $Verbosity -eq "Error" } + } + } + + Context "When Get-PowershellModuleScopeMap returns empty" { + It "Should warn and return true" { + Mock Get-PowershellModuleScopeMap { @() } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeTrue + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "No PowerShell module install paths found" -and $Verbosity -eq "Warning" } + } + } + + Context "When Read-DevSetupEnvFile throws an exception" { + It "Should return false and log error" { + Mock Read-DevSetupEnvFile { throw "Config read failed" } + $result = Invoke-PowershellModulesExport -Config "test.yaml" + $result | Should -BeFalse + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to read configuration file" -and $Verbosity -eq "Error" } + } + } + Context "When no modules are found" { It "Should warn and return true" { Mock Get-InstalledModule { @() } @@ -45,9 +107,10 @@ Describe "Invoke-PowershellModulesExport" { Context "When core dependency modules are present" { It "Should skip core dependency modules" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" Mock Get-InstalledModule { @( - @{ Name = "ModuleA"; Version = [version]"1.0.0" }, - @{ Name = "ModuleB"; Version = [version]"2.0.0" } + @{ Name = "ModuleA"; Version = [version]"1.0.0"; InstalledLocation = (Join-Path $userPath "ModuleA") }, + @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") } ) } Mock Get-DevSetupManifest { @{ RequiredModules = @("ModuleA") } } Invoke-PowershellModulesExport -Config "test.yaml" @@ -56,6 +119,19 @@ Describe "Invoke-PowershellModulesExport" { } } + Context "When core dependency modules are hashtable format" { + It "Should skip hashtable format core dependency modules" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Get-InstalledModule { @( + @{ Name = "ModuleA"; Version = [version]"1.0.0"; InstalledLocation = (Join-Path $userPath "ModuleA") }, + @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") } + ) } + Mock Get-DevSetupManifest { @{ RequiredModules = @(@{ ModuleName = "ModuleA"; ModuleVersion = "1.0.0" }) } } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Adding module: ModuleB" } + } + } + Context "When modules are found and added to config" { It "Should add new modules to YAML data" { $result = Invoke-PowershellModulesExport -Config "test.yaml" @@ -66,8 +142,9 @@ Describe "Invoke-PowershellModulesExport" { Context "When module version changes" { It "Should update the module version in the config" { - Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; minimumVersion = "1.0.0"; scope = "CurrentUser" }) } } } } } - Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0" }) } + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; minimumVersion = "1.0.0"; scope = "CurrentUser" }); scope = "CurrentUser" } } } } } + Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") }) } Invoke-PowershellModulesExport -Config "test.yaml" Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Updating module: ModuleB" } } @@ -75,8 +152,9 @@ Describe "Invoke-PowershellModulesExport" { Context "When module exists but has no version" { It "Should add minimumVersion to the module" { - Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; scope = "CurrentUser" }) } } } } } - Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0" }) } + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; scope = "CurrentUser" }); scope = "CurrentUser" } } } } } + Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") }) } Invoke-PowershellModulesExport -Config "test.yaml" Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Updating module version: ModuleB" } } @@ -84,10 +162,75 @@ Describe "Invoke-PowershellModulesExport" { Context "When module is unchanged" { It "Should skip updating the module" { - Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; minimumVersion = "2.0.0"; scope = "CurrentUser" }) } } } } } - Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0" }) } + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Read-DevSetupEnvFile { + @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @(@{ + name = "ModuleB"; + minimumVersion = "2.0.0"; + scope = "CurrentUser" + }); + scope = "CurrentUser" + } + } + } + } + } + Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") }) } + Mock Get-DevSetupManifest { @{ RequiredModules = @() } } # No core dependencies to exclude ModuleB + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Skipping module.*No Change.*ModuleB" } + } + } + + Context "When module exists with version property instead of minimumVersion" { + It "Should use version property for comparison and detect change" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; version = "1.0.0"; scope = "CurrentUser" }); scope = "CurrentUser" } } } } } + Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = (Join-Path $userPath "ModuleB") }) } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Updating module: ModuleB \(1\.0\.0 -> 2\.0\.0\)" } + } + } + + Context "When module has unknown scope" { + It "Should skip module with unknown installation location" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + $unknownPath = Join-Path $TestDrive "Some" "Unknown" "Path" "UnknownModule" + + Mock Get-InstalledModule { @(@{ Name = "UnknownModule"; Version = [version]"1.0.0"; InstalledLocation = $unknownPath }) } + Mock Get-PowershellModuleScopeMap { @( + @{ Path = $userPath; Scope = "CurrentUser" }, + @{ Path = $systemPath; Scope = "AllUsers" } + ) } + Mock Get-DevSetupManifest { @{ RequiredModules = @() } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(); scope = "UnknownScope" } } } } } + Invoke-PowershellModulesExport -Config "test.yaml" + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Skipping module with unknown scope: UnknownModule" -and $Verbosity -eq "Verbose" } + } + } + + Context "When module scope differs from default scope" { + It "Should override default scope with detected scope from installation path" { + $systemPath = Join-Path $TestDrive "Program Files" "WindowsPowerShell" "Modules" + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" + $systemModulePath = Join-Path $systemPath "SystemModule" + + Mock Get-InstalledModule { @( + @{ Name = "SystemModule"; Version = [version]"1.0.0"; InstalledLocation = $systemModulePath } + ) } + Mock Get-PowershellModuleScopeMap { @( + @{ Path = $userPath; Scope = "CurrentUser" }, + @{ Path = $systemPath; Scope = "AllUsers" } + ) } + Mock Get-DevSetupManifest { @{ RequiredModules = @() } } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(); scope = "CurrentUser" } } } } } Invoke-PowershellModulesExport -Config "test.yaml" - #Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Skipping module (No Change): ModuleB" } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Found module: SystemModule.*scope: AllUsers" } } } @@ -100,13 +243,7 @@ Describe "Invoke-PowershellModulesExport" { } } - Context "When OutFile is specified" { - It "Should write YAML output to the specified file" { - $result = Invoke-PowershellModulesExport -Config "test.yaml" -OutFile "out.yaml" - $result | Should -BeTrue - Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It - } - } + Context "When Out-File fails" { It "Should write error and return false" { @@ -117,12 +254,12 @@ Describe "Invoke-PowershellModulesExport" { } } - Context "When an unexpected error occurs" { + Context "When an unexpected error occurs during module retrieval" { It "Should write error and return false" { Mock Get-InstalledModule { throw "Unexpected error" } $result = Invoke-PowershellModulesExport -Config "test.yaml" $result | Should -BeFalse - Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Error converting PowerShell modules" -and $Verbosity -eq "Error"} + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to retrieve installed PowerShell modules" -and $Verbosity -eq "Error"} } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 index 84f9f79..39b4a34 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 @@ -71,181 +71,179 @@ Function Invoke-PowershellModulesExport { [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Config, - [Parameter(Mandatory = $false)] - [ValidateNotNullOrEmpty()] - [string]$OutFile, [switch]$DryRun ) try { # Check if running as administrator if (-not (Test-RunningAsAdmin)) { - throw "This operation requires administrator privileges. Please run as administrator." + Write-StatusMessage "This operation requires administrator privileges. Please run as administrator." -Verbosity Error + return $false } + } catch { + Write-StatusMessage "Failed to validate administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - # Get installed PowerShell modules - Write-StatusMessage "- Getting list of installed PowerShell modules..." -ForegroundColor Gray + # Get installed PowerShell modules + Write-StatusMessage "- Getting list of installed PowerShell modules..." -ForegroundColor Gray + try { $installedModules = Get-InstalledModule -ErrorAction SilentlyContinue + } catch { + Write-StatusMessage "Failed to retrieve installed PowerShell modules: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - if (-not $installedModules) { - Write-StatusMessage "No PowerShell modules found or PowerShellGet is not available." -Verbosity Warning - return $true - } + if (-not $installedModules) { + Write-StatusMessage "No PowerShell modules found or PowerShellGet is not available." -Verbosity Warning + return $true + } - $powershellModules = @() + $powershellModules = @() - # Get core dependency modules to skip from DevSetup manifest + # Get core dependency modules to skip from DevSetup manifest + try { $manifest = Get-DevSetupManifest - $coreModulesToSkip = @() - if ($manifest -and $manifest.RequiredModules) { - $coreModulesToSkip = $manifest.RequiredModules | ForEach-Object { - if ($_ -is [string]) { - $_ - } elseif ($_ -is [hashtable] -and $_.ModuleName) { - $_.ModuleName - } elseif ($_ -is [hashtable] -and $_.name) { - $_.name - } + } catch { + Write-StatusMessage "Failed to read DevSetup manifest: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + # Valid formats for core modules in manifest: + # @('ModuleName1', 'ModuleName2') + # or + # @(@{ ModuleName = 'ModuleName1'; ModuleVersion = '1.0.0' }, @{ name = 'ModuleName2'; RequiredVersion = '2.0.0' }) + # In the second version, ModuleVersion and RequiredVersion are mutually exclusive + # and only one should be used per module entry. + + $coreModulesToSkip = @() + if ($manifest -and $manifest.RequiredModules) { + $coreModulesToSkip = $manifest.RequiredModules | ForEach-Object { + if ($_ -is [string]) { + $_ + } elseif ($_ -is [hashtable] -and $_.ModuleName) { + $_.ModuleName } } + } - foreach ($module in $installedModules) { - # Skip core dependency modules - if ($module.Name -in $coreModulesToSkip) { - Write-StatusMessage "Skipping core dependency module: $($module.Name)" -Verbosity Verbose - continue - } - - # Get module scope information - $moduleInfo = Get-Module -Name $module.Name -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1 - - # Check if module is in CurrentUser or AllUsers scope - $modulePath = $moduleInfo.ModuleBase - $scope = "Unknown" - - if ($modulePath -like "*\WindowsPowerShell\Modules\*" -or $modulePath -like "*\PowerShell\Modules\*") { - if ($modulePath -like "*$env:USERPROFILE*") { - $scope = "CurrentUser" - } else { - $scope = "AllUsers" - } + try { + $InstallPaths = Get-PowershellModuleScopeMap + } catch { + Write-StatusMessage "Failed to get PowerShell module scope map: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + if(-not $InstallPaths -or $InstallPaths.Count -eq 0) { + Write-StatusMessage "No PowerShell module install paths found." -Verbosity Warning + return $true + } + + try { + $YamlData = Read-DevSetupEnvFile -Config $Config + } catch { + Write-StatusMessage "Failed to read configuration file $Config`: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + foreach ($module in $installedModules) { + # Skip core dependency modules + if ($module.Name -in $coreModulesToSkip) { + Write-StatusMessage "Skipping core dependency module: $($module.Name)" -Verbosity Verbose + continue + } + + $moduleScope = ($InstallPaths | ForEach-Object { + if ($module.InstalledLocation -like "$($_.Path)$([System.IO.Path]::DirectorySeparatorChar)*") { + $_.Scope } - - if ($scope -eq "CurrentUser" -or $scope -eq "AllUsers") { - Write-StatusMessage "Found module: $($module.Name) (version: $($module.Version), scope: $scope)" -Verbosity Debug - $powershellModules += @{ - name = $module.Name - version = $module.Version.ToString() - scope = $scope - } - } else { - Write-StatusMessage "Skipping module with unknown scope: $($module.Name)" -Verbosity Verbose + }) + + if ($moduleScope -eq "CurrentUser" -or $moduleScope -eq "AllUsers") { + Write-StatusMessage "Found module: $($module.Name) (version: $($module.Version), scope: $moduleScope)" -Verbosity Debug + $powershellModules += @{ + name = $module.Name + version = $module.Version.ToString() + scope = $moduleScope } + } else { + Write-StatusMessage "Skipping module with unknown scope: $($module.Name)" -Verbosity Verbose } + } - Write-StatusMessage " - Found $($powershellModules.Count) PowerShell modules in CurrentUser or AllUsers scope (excluding core dependencies)" -Verbosity Debug - - # Read existing YAML configuration - $YamlData = Read-DevSetupEnvFile -Config $Config + Write-StatusMessage " - Found $($powershellModules.Count) PowerShell modules in CurrentUser or AllUsers scope (excluding core dependencies)" -Verbosity Debug - # Ensure powershell-specific sections exist - if (-not $YamlData.devsetup.dependencies.powershell) { $YamlData.devsetup.dependencies.powershell = @{} } - if (-not $YamlData.devsetup.dependencies.powershell.modules) { $YamlData.devsetup.dependencies.powershell.modules = @() } + # Add modules to YAML data + foreach ($module in $powershellModules) { + # Check if module already exists + $existingModule = $YamlData.devsetup.dependencies.powershell.modules | Where-Object { + ($_.name -eq $module.name) + } - # Add modules to YAML data - foreach ($module in $powershellModules) { - # Check if module already exists - $existingModule = $YamlData.devsetup.dependencies.powershell.modules | Where-Object { - ($_ -is [string] -and $_ -eq $module.name) -or - ($_.name -eq $module.name) + if (-not $existingModule) { + Write-StatusMessage "- Adding module: $($module.name) ($($module.version), $($module.scope))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + $YamlData.devsetup.dependencies.powershell.modules += @{ + name = $module.name + minimumVersion = $module.version + version = "" + scope = $module.scope } + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + # Module exists, check if version has changed + $existingVersion = $null + if (-not ([string]::IsNullOrEmpty($existingModule.minimumVersion))) { + $existingVersion = $existingModule.minimumVersion + } elseif (-not ([string]::IsNullOrEmpty($existingModule.version))) { + $existingVersion = $existingModule.version + } + + if ($existingVersion -and $existingVersion -ne $module.version) { + Write-StatusMessage "- Updating module: $($module.name) ($existingVersion -> $($module.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline - if (-not $existingModule) { - Write-StatusMessage " - Adding module: $($module.name) ($($module.version), $($module.scope))" -ForegroundColor Gray - $YamlData.devsetup.dependencies.powershell.modules += @{ + # Find index and update + $index = $YamlData.devsetup.dependencies.powershell.modules.IndexOf($existingModule) + $YamlData.devsetup.dependencies.powershell.modules[$index] = @{ name = $module.name minimumVersion = $module.version - version = "" scope = $module.scope + version = "" } - } else { - # Module exists, check if version has changed - $existingVersion = $null - if ((-not ($existingModule -is [string])) -and $existingModule.minimumVersion) { - $existingVersion = $existingModule.minimumVersion - } elseif ((-not ($existingModule -is [string])) -and $existingModule.version) { - $existingVersion = $existingModule.version - } + Write-StatusMessage "[OK]" -ForegroundColor Green + } elseif (-not $existingVersion) { + Write-StatusMessage "- Updating module version: $($module.name)" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline - if ($existingVersion -and $existingVersion -ne $module.version) { - Write-StatusMessage " - Updating module: $($module.name) ($existingVersion -> $($module.version))" -ForegroundColor Gray - - # Find index and update - $index = $YamlData.devsetup.dependencies.powershell.modules.IndexOf($existingModule) - - # Preserve existing module structure but update version - if ($existingModule -is [string]) { - # Convert string to hashtable with version - $YamlData.devsetup.dependencies.powershell.modules[$index] = @{ - name = $module.name - minimumVersion = $module.version - scope = $module.scope - version = "" - } - } else { - # Update existing hashtable - $YamlData.devsetup.dependencies.powershell.modules[$index].minimumVersion = $module.version - if (-not $existingModule.scope) { - $YamlData.devsetup.dependencies.powershell.modules[$index].scope = $module.scope - } - } - } elseif (-not $existingVersion) { - Write-StatusMessage " - Updating module version: $($module.name)" -ForegroundColor Gray - - # Find index and add version - $index = $YamlData.devsetup.dependencies.powershell.modules.IndexOf($existingModule) - - if ($existingModule -is [string]) { - # Convert string to hashtable with version - $YamlData.devsetup.dependencies.powershell.modules[$index] = @{ - name = $module.name - minimumVersion = $module.version - scope = $module.scope - version = "" - } - } else { - # Add version to existing hashtable - $YamlData.devsetup.dependencies.powershell.modules[$index].minimumVersion = $module.version - if (-not $existingModule.scope) { - $YamlData.devsetup.dependencies.powershell.modules[$index].scope = $module.scope - } - } - } else { - Write-StatusMessage " - Skipping module (No Change): $($module.name) ($($module.version))" -ForegroundColor Gray + $index = $YamlData.devsetup.dependencies.powershell.modules.IndexOf($existingModule) + $YamlData.devsetup.dependencies.powershell.modules[$index] = @{ + name = $module.name + minimumVersion = $module.version + scope = $module.scope + version = "" } + Write-StatusMessage "[OK]" -ForegroundColor Green + } else { + Write-StatusMessage "- Skipping module (No Change): $($module.name) ($($module.version))" -ForegroundColor Gray -Indent 2 -Width 112 -NoNewline + Write-StatusMessage "[OK]" -ForegroundColor Green } } - - # Handle output based on parameters - # Determine output file - $outputFile = if ($OutFile) { $OutFile } else { $Config } - - try { - Write-StatusMessage "`nSaving configuration to: $outputFile" -Verbosity Debug - $YamlData | Update-DevSetupEnvFile -EnvFilePath $Config -WhatIf:$DryRun - Write-StatusMessage "Configuration saved successfully!" -Verbosity Debug - } - catch { - Write-StatusMessage "Failed to save configuration to $outputFile`: $_" -Verbosity Error - return $false - } - - Write-StatusMessage "PowerShell modules conversion completed!" -ForegroundColor Green - return $true + } + + try { + Write-StatusMessage "`nSaving configuration to: $Config" -Verbosity Debug + $YamlData | Update-DevSetupEnvFile -EnvFilePath $Config -WhatIf:$DryRun + Write-StatusMessage "Configuration saved successfully!" -Verbosity Debug } catch { - Write-StatusMessage "Error converting PowerShell modules: $_" -Verbosity Error + Write-StatusMessage "Failed to save configuration to $Config`: $_" -Verbosity Error Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } + + Write-StatusMessage "PowerShell modules conversion completed!" -ForegroundColor Green + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.Tests.ps1 index c19e092..6bec694 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.Tests.ps1 @@ -8,76 +8,76 @@ BeforeAll { Mock Write-Error {} Mock Write-Warning {} Mock Write-Host {} + Mock Install-PowershellModule { return $true } } Describe "Invoke-PowershellModulesInstall" { Context "When YAML configuration is missing PowerShell modules" { - It "Should return false" { - $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ } } } } + It "Should return true (handles empty module list gracefully)" { + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @() } } } } $result = Invoke-PowershellModulesInstall -YamlData $yamlData - $result | Should -Be $false + $result | Should -Be $true } } Context "When YAML configuration is missing dependencies" { - It "Should return false" { + It "Should return true (handles missing dependencies gracefully)" { $yamlData = @{ devsetup = @{ } } $result = Invoke-PowershellModulesInstall -YamlData $yamlData - $result | Should -Be $false + $result | Should -Be $true } } Context "When AllUsers scope is specified but not running as admin" { - It "Should return false" { + It "Should return false and show admin error" { Mock Test-RunningAsAdmin { return $false } $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ scope = "AllUsers" - modules = @("posh-git") + modules = @( + @{ name = "posh-git" } + ) } } } } $result = Invoke-PowershellModulesInstall -YamlData $yamlData $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "administrator privileges" } } } - Context "When modules are installed successfully (string format)" { - It "Should install all modules and return true" { - $script:installCalls = @() - Mock Install-PowershellModule -MockWith { - param($ModuleName) - $script:installCalls += $ModuleName - return $true - } + Context "When Test-RunningAsAdmin throws exception" { + It "Should return false and log error" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ - modules = @("posh-git", "PSReadLine") + scope = "AllUsers" + modules = @( + @{ name = "posh-git" } + ) } } } } $result = Invoke-PowershellModulesInstall -YamlData $yamlData - $result | Should -Be $true - $installCalls | Should -Contain "posh-git" - $installCalls | Should -Contain "PSReadLine" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "Failed to validate administrator privileges" } } } Context "When modules are installed successfully (object format)" { It "Should install all modules and return true" { - $script:installCalls = @() Mock Install-PowershellModule -MockWith { - param($ModuleName) - $script:installCalls += $ModuleName + param($ModuleName, $Force, $AllowClobber, $Scope, $Version, $WhatIf) return $true - } + } -ParameterFilter { $ModuleName } + $yamlData = @{ devsetup = @{ dependencies = @{ @@ -92,53 +92,104 @@ Describe "Invoke-PowershellModulesInstall" { } $result = Invoke-PowershellModulesInstall -YamlData $yamlData $result | Should -Be $true - $installCalls | Should -Contain "posh-git" - $installCalls | Should -Contain "PSReadLine" + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "PSReadLine" } } } - Context "When some modules fail to install" { - It "Should continue and return true" { - $script:installCalls = @() + Context "When modules use default scope and settings" { + It "Should use global scope and default force/allowClobber settings" { Mock Install-PowershellModule -MockWith { - param($ModuleName) - $script:installCalls += $ModuleName - if ($ModuleName -eq "PSReadLine") { return $false } + param($ModuleName, $Force, $AllowClobber, $Scope, $Version, $WhatIf) return $true + } -ParameterFilter { $ModuleName } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + scope = "CurrentUser" + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Install-PowershellModule -ParameterFilter { + $ModuleName -eq "posh-git" -and + $Scope -eq "CurrentUser" -and + $Force -eq $true -and + $AllowClobber -eq $true } + } + } + + Context "When module has no version specified" { + It "Should install latest version" { + Mock Install-PowershellModule { return $true } + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ - modules = @("posh-git", "PSReadLine", "PowerShellGet") + scope = "CurrentUser" + modules = @( + @{ name = "posh-git" } + ) } } } } $result = Invoke-PowershellModulesInstall -YamlData $yamlData $result | Should -Be $true - $installCalls | Should -Contain "posh-git" - $installCalls | Should -Contain "PSReadLine" - $installCalls | Should -Contain "PowerShellGet" + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -match "latest version" } } } - Context "When module entry is empty or missing name" { - It "Should skip invalid entries and return true" { - $script:installCalls = @() + Context "When some modules fail to install" { + It "Should continue and return true" { Mock Install-PowershellModule -MockWith { param($ModuleName) - $script:installCalls += $ModuleName + if ($ModuleName -eq "PSReadLine") { return $false } return $true + } -ParameterFilter { $ModuleName } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git" }, + @{ name = "PSReadLine" }, + @{ name = "PowerShellGet" } + ) + } + } + } } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "PSReadLine" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "PowerShellGet" } + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" } + } + } + + Context "When module entry is null" { + It "Should skip null entries and return true" { + Mock Install-PowershellModule { return $true } + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @( $null, - @{ minimumVersion = "1.0.0" }, - "posh-git" + @{ name = "posh-git" } ) } } @@ -146,25 +197,57 @@ Describe "Invoke-PowershellModulesInstall" { } $result = Invoke-PowershellModulesInstall -YamlData $yamlData $result | Should -Be $true - $installCalls | Should -Contain "posh-git" - $installCalls.Count | Should -Be 1 + Assert-MockCalled Install-PowershellModule -Times 1 + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } } } Context "When an exception occurs during installation" { - It "Should catch and return false" { - Mock Install-PowershellModule { throw "Unexpected error" } + It "Should catch exception, continue, and return true" { + Mock Install-PowershellModule { + param($ModuleName) + if ($ModuleName -eq "ErrorModule") { throw "Installation error" } + return $true + } -ParameterFilter { $ModuleName } + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ - modules = @("posh-git") + modules = @( + @{ name = "ErrorModule" }, + @{ name = "GoodModule" } + ) } } } } $result = Invoke-PowershellModulesInstall -YamlData $yamlData - $result | Should -Be $false + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "Error installing PowerShell module ErrorModule" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "ErrorModule" } + Assert-MockCalled Install-PowershellModule -ParameterFilter { $ModuleName -eq "GoodModule" } + } + } + + Context "When DryRun is specified" { + It "Should pass WhatIf to Install-PowershellModule" { + Mock Install-PowershellModule { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesInstall -YamlData $yamlData -DryRun + $result | Should -Be $true + Assert-MockCalled Install-PowershellModule -ParameterFilter { $WhatIf -eq $true } } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.ps1 index 198ee74..d93fd0b 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesInstall.ps1 @@ -86,80 +86,65 @@ Function Invoke-PowershellModulesInstall { [switch]$DryRun = $false ) + $modules = $YamlData.devsetup.dependencies.powershell.modules + + # Get global scope setting from YAML, default to CurrentUser + $globalScope = 'AllUsers' + if ($YamlData.devsetup.dependencies.powershell.scope) { + $globalScope = $YamlData.devsetup.dependencies.powershell.scope + } + try { - Write-StatusMessage "- Installing PowerShell modules from configuration:" -ForegroundColor Cyan - # Check if PowerShell modules dependencies exist - if (-not $YamlData -or -not $YamlData.devsetup -or -not $YamlData.devsetup.dependencies -or -not $YamlData.devsetup.dependencies.powershell -or -not $YamlData.devsetup.dependencies.powershell.modules) { - Write-StatusMessage "PowerShell modules not found in YAML configuration. Skipping installation." -Verbosity Debug - Write-StatusMessage "- PowerShell modules installation completed! Processed 0 modules.`n" -ForegroundColor Green - return $false + # Check if running as administrator when global scope is AllUsers + if ($globalScope -eq 'AllUsers' -and (-not (Test-RunningAsAdmin))) { + throw "PowerShell module installation to AllUsers scope requires administrator privileges. Please run as administrator or set powershellModuleScope to CurrentUser." } + } catch { + Write-StatusMessage "Failed to validate administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "- Installing PowerShell modules from configuration:" -ForegroundColor Cyan + + $moduleCount = 0 + + foreach ($module in $modules) { + if (-not $module) { continue } - $modules = $YamlData.devsetup.dependencies.powershell.modules + # Determine scope for this module (module-specific overrides global) + $moduleScope = if ($module.scope) { $module.scope } else { $globalScope } - # Get global scope setting from YAML, default to CurrentUser - $globalScope = 'AllUsers' - if ($YamlData.devsetup.dependencies.powershell.scope) { - $globalScope = $YamlData.devsetup.dependencies.powershell.scope + # Set defaults and build parameters + $installParams = @{ + ModuleName = $module.name + Force = if ($module.force -is [bool]) { $module.force } else { $true } + AllowClobber = if ($module.allowClobber -is [bool]) { $module.allowClobber } else { $true } + Scope = $moduleScope + WhatIf = $DryRun } - # Check if running as administrator when global scope is AllUsers - if ($globalScope -eq 'AllUsers' -and (-not (Test-RunningAsAdmin))) { - throw "PowerShell module installation to AllUsers scope requires administrator privileges. Please run as administrator or set powershellModuleScope to CurrentUser." + if ($module.minimumVersion) { + $installParams.Version = $module.minimumVersion + Write-StatusMessage "- Installing PowerShell module: $($module.name) (version: $($module.minimumVersion), scope: $moduleScope)" -ForegroundColor Gray -Width 112 -NoNewLine -Indent 2 + } else { + Write-StatusMessage "- Installing PowerShell module: $($module.name) (latest version) to $moduleScope scope" -ForegroundColor Gray -Width 112 -NoNewLine -Indent 2 } - - $moduleCount = 0 - - foreach ($module in $modules) { - if (-not $module) { continue } - - $moduleCount++ - - # Normalize module to object format - if ($module -is [string]) { - $moduleObj = @{ name = $module } - } else { - $moduleObj = $module - } - - # Validate module name - if ([string]::IsNullOrEmpty($moduleObj.name)) { - Write-StatusMessage "Module entry #$moduleCount has no name specified, skipping" -Verbosity Warning - continue - } - - # Determine scope for this module (module-specific overrides global) - $moduleScope = if ($moduleObj.scope) { $moduleObj.scope } else { $globalScope } - - # Set defaults and build parameters - $installParams = @{ - ModuleName = $moduleObj.name - Force = if ($moduleObj.force -is [bool]) { $moduleObj.force } else { $true } - AllowClobber = if ($moduleObj.allowClobber -is [bool]) { $moduleObj.allowClobber } else { $true } - Scope = $moduleScope - WhatIf = $DryRun - } - - if ($moduleObj.minimumVersion) { - $installParams.Version = $moduleObj.minimumVersion - Write-StatusMessage "- Installing PowerShell module: $($moduleObj.name) (version: $($moduleObj.minimumVersion), scope: $moduleScope)" -ForegroundColor Gray -Width 112 -NoNewLine -Indent 2 + try { + # Attempt to install the module + if (-not (Install-PowerShellModule @installParams)) { + Write-StatusMessage "[FAILED]" -ForegroundColor Red } else { - Write-StatusMessage "- Installing PowerShell module: $($moduleObj.name) (latest version) to $moduleScope scope" -ForegroundColor Gray -Width 112 -NoNewLine -Indent 2 - } - - if ((Install-PowerShellModule @installParams)) { Write-StatusMessage "[OK]" -ForegroundColor Green - } else { - Write-StatusMessage "[FAILED]" -ForegroundColor Red + $moduleCount++ } + } catch { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + Write-StatusMessage "Error installing PowerShell module $($module.name): $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error } - Write-StatusMessage "- PowerShell modules installation completed! Processed $moduleCount modules.`n" -ForegroundColor Green - return $true - } - catch { - Write-StatusMessage "Error installing PowerShell modules: $_" -Verbosity Error - Write-StatusMessage $_.ScriptStackTrace -Verbosity Error - return $false } + Write-StatusMessage "- PowerShell modules installation completed! Processed $moduleCount modules.`n" -ForegroundColor Green + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.Tests.ps1 index 99417fd..a00e5b4 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.Tests.ps1 @@ -8,76 +8,73 @@ BeforeAll { Mock Write-Error { } Mock Test-RunningAsAdmin { return $true } Mock Write-Host { } + Mock Uninstall-PowershellModule { return $true } } Describe "Invoke-PowershellModulesUninstall" { Context "When YAML configuration is missing PowerShell modules" { - It "Should return false and warn" { - $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ } } } } + It "Should return true (handles empty module list gracefully)" { + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @() } } } } $result = Invoke-PowershellModulesUninstall -YamlData $yamlData - $result | Should -Be $false + $result | Should -Be $true } } Context "When YAML configuration is missing dependencies" { - It "Should return false and warn" { + It "Should return true (handles missing dependencies gracefully)" { $yamlData = @{ devsetup = @{ } } $result = Invoke-PowershellModulesUninstall -YamlData $yamlData - $result | Should -Be $false + $result | Should -Be $true } } Context "When AllUsers scope is specified but not running as admin" { - It "Should return false" { + It "Should return false and log admin error" { Mock Test-RunningAsAdmin { return $false } $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ scope = "AllUsers" - modules = @("posh-git") + modules = @( + @{ name = "posh-git" } + ) } } } } $result = Invoke-PowershellModulesUninstall -YamlData $yamlData $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "administrator privileges" } } } - Context "When modules are uninstalled successfully (string format)" { - It "Should uninstall all modules and return true" { - $script:uninstallCalls = @() - Mock Uninstall-PowershellModule -MockWith { - param($ModuleName) - $script:uninstallCalls += $ModuleName - return $true - } + Context "When Test-RunningAsAdmin throws exception" { + It "Should return false and log error" { + Mock Test-RunningAsAdmin { throw "Admin check failed" } $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ - modules = @("posh-git", "PSReadLine") + scope = "AllUsers" + modules = @( + @{ name = "posh-git" } + ) } } } } $result = Invoke-PowershellModulesUninstall -YamlData $yamlData - $result | Should -Be $true - $uninstallCalls | Should -Contain "posh-git" - $uninstallCalls | Should -Contain "PSReadLine" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "Failed to validate administrator privileges" } } } Context "When modules are uninstalled successfully (object format)" { It "Should uninstall all modules and return true" { - $script:uninstallCalls = @() - Mock Uninstall-PowershellModule -MockWith { - param($ModuleName) - $script:uninstallCalls += $ModuleName - return $true - } + Mock Uninstall-PowershellModule { return $true } -ParameterFilter { $ModuleName } + $yamlData = @{ devsetup = @{ dependencies = @{ @@ -92,53 +89,99 @@ Describe "Invoke-PowershellModulesUninstall" { } $result = Invoke-PowershellModulesUninstall -YamlData $yamlData $result | Should -Be $true - $uninstallCalls | Should -Contain "posh-git" - $uninstallCalls | Should -Contain "PSReadLine" + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "PSReadLine" } + } + } + + Context "When modules use default scope" { + It "Should use global scope setting" { + Mock Uninstall-PowershellModule { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + scope = "CurrentUser" + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + } + } + + Context "When module has no version specified" { + It "Should uninstall latest version" { + Mock Uninstall-PowershellModule { return $true } + + $yamlData = @{ + devsetup = @{ + dependencies = @{ + powershell = @{ + modules = @( + @{ name = "posh-git" } + ) + } + } + } + } + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -match "latest version" } } } Context "When some modules fail to uninstall" { It "Should continue and return true" { - $script:uninstallCalls = @() Mock Uninstall-PowershellModule -MockWith { param($ModuleName) - $script:uninstallCalls += $ModuleName if ($ModuleName -eq "PSReadLine") { return $false } return $true - } + } -ParameterFilter { $ModuleName } + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ - modules = @("posh-git", "PSReadLine", "PowerShellGet") + modules = @( + @{ name = "posh-git" }, + @{ name = "PSReadLine" }, + @{ name = "PowerShellGet" } + ) } } } } $result = Invoke-PowershellModulesUninstall -YamlData $yamlData $result | Should -Be $true - $uninstallCalls | Should -Contain "posh-git" - $uninstallCalls | Should -Contain "PSReadLine" - $uninstallCalls | Should -Contain "PowerShellGet" + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "posh-git" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "PSReadLine" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "PowerShellGet" } + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "[FAILED]" -and $ForegroundColor -eq "Red" } } } - Context "When module entry is empty or missing name" { - It "Should skip invalid entries and return true" { - $script:uninstallCalls = @() - Mock Uninstall-PowershellModule -MockWith { + Context "When an exception occurs during uninstallation" { + It "Should catch exception, continue, and return true" { + Mock Uninstall-PowershellModule { param($ModuleName) - $script:uninstallCalls += $ModuleName + if ($ModuleName -eq "ErrorModule") { throw "Uninstallation error" } return $true - } + } -ParameterFilter { $ModuleName } + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @( - $null, - @{ minimumVersion = "1.0.0" }, - "posh-git" + @{ name = "ErrorModule" }, + @{ name = "GoodModule" } ) } } @@ -146,25 +189,30 @@ Describe "Invoke-PowershellModulesUninstall" { } $result = Invoke-PowershellModulesUninstall -YamlData $yamlData $result | Should -Be $true - $uninstallCalls | Should -Contain "posh-git" - $uninstallCalls.Count | Should -Be 1 + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq "Error" -and $Message -match "Error uninstalling module ErrorModule" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "ErrorModule" } + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $ModuleName -eq "GoodModule" } } } - Context "When an exception occurs during uninstallation" { - It "Should catch and return false" { - Mock Uninstall-PowershellModule { throw "Unexpected error" } + Context "When DryRun is specified" { + It "Should pass WhatIf to Uninstall-PowershellModule" { + Mock Uninstall-PowershellModule { return $true } + $yamlData = @{ devsetup = @{ dependencies = @{ powershell = @{ - modules = @("posh-git") + modules = @( + @{ name = "posh-git" } + ) } } } } - $result = Invoke-PowershellModulesUninstall -YamlData $yamlData - $result | Should -Be $false + $result = Invoke-PowershellModulesUninstall -YamlData $yamlData -DryRun + $result | Should -Be $true + Assert-MockCalled Uninstall-PowershellModule -ParameterFilter { $WhatIf -eq $true } } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.ps1 index 7faecde..e52fa7b 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesUninstall.ps1 @@ -85,76 +85,60 @@ Function Invoke-PowershellModulesUninstall { [switch]$DryRun ) + $modules = $YamlData.devsetup.dependencies.powershell.modules + + # Get global scope setting from YAML, default to CurrentUser + $globalScope = if ($YamlData.devsetup.dependencies.powershell.scope) { + $YamlData.devsetup.dependencies.powershell.scope + } else { + 'CurrentUser' + } + try { - # Check if PowerShell modules dependencies exist - if (-not $YamlData -or -not $YamlData.devsetup -or -not $YamlData.devsetup.dependencies -or -not $YamlData.devsetup.dependencies.powershell -or -not $YamlData.devsetup.dependencies.powershell.modules) { - Write-StatusMessage "PowerShell modules not found in YAML configuration. Skipping uninstallation." -Verbosity Warning - return $false - } - - $modules = $YamlData.devsetup.dependencies.powershell.modules - - # Get global scope setting from YAML, default to CurrentUser - $globalScope = if ($YamlData.devsetup.dependencies.powershell.scope) { - $YamlData.devsetup.dependencies.powershell.scope - } else { - 'CurrentUser' - } - # Check if running as administrator when global scope is AllUsers if ($globalScope -eq 'AllUsers' -and (-not (Test-RunningAsAdmin))) { - throw "PowerShell module uninstallation to AllUsers scope requires administrator privileges. Please run as administrator or set powershellModuleScope to CurrentUser." + Write-StatusMessage "PowerShell module uninstallation to AllUsers scope requires administrator privileges. Please run as administrator or set powershellModuleScope to CurrentUser." -Verbosity Error + return $false } + } catch { + Write-StatusMessage "Failed to validate administrator privileges: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } - Write-StatusMessage "- Uninstalling PowerShell modules from configuration:" -ForegroundColor Cyan - - $moduleCount = 0 - - foreach ($module in $modules) { - if (-not $module) { continue } - - $moduleCount++ - - # Normalize module to object format - if ($module -is [string]) { - $moduleObj = @{ name = $module } - } else { - $moduleObj = $module - } - - # Validate module name - if ([string]::IsNullOrEmpty($moduleObj.name)) { - Write-StatusMessage "Module entry #$moduleCount has no name specified, skipping" -Verbosity Warning - continue - } - - # Determine scope for this module (module-specific overrides global) - $moduleScope = if ($moduleObj.scope) { $moduleObj.scope } else { $globalScope } - - # Set defaults and build parameters - $installParams = @{ - ModuleName = $moduleObj.name - WhatIf = $DryRun - } - - if ($moduleObj.minimumVersion) { - Write-StatusMessage "- Uninstalling PowerShell module: $($moduleObj.name) (version: $($moduleObj.minimumVersion), scope: $moduleScope)" -ForegroundColor Gray -Width 100 -NoNewLine -Indent 2 - } else { - Write-StatusMessage "- Uninstalling PowerShell module: $($moduleObj.name) (latest version) to $moduleScope scope" -ForegroundColor Gray -Width 100 -NoNewLine -Indent 2 - } + Write-StatusMessage "- Uninstalling PowerShell modules from configuration:" -ForegroundColor Cyan + + $moduleCount = 0 + + foreach ($module in $modules) { + # Determine scope for this module (module-specific overrides global) + $moduleScope = if ($module.scope) { $module.scope } else { $globalScope } + + # Set defaults and build parameters + $installParams = @{ + ModuleName = $module.name + WhatIf = $DryRun + } + + if ($module.minimumVersion) { + Write-StatusMessage "- Uninstalling PowerShell module: $($module.name) (version: $($module.minimumVersion), scope: $moduleScope)" -ForegroundColor Gray -Width 100 -NoNewLine -Indent 2 + } else { + Write-StatusMessage "- Uninstalling PowerShell module: $($module.name) (latest version) to $moduleScope scope" -ForegroundColor Gray -Width 100 -NoNewLine -Indent 2 + } + try { if ((Uninstall-PowerShellModule @installParams)) { Write-StatusMessage "[OK]" -ForegroundColor Green + $moduleCount++ } else { Write-StatusMessage "[FAILED]" -ForegroundColor Red } + } catch { + Write-StatusMessage "[FAILED]" -ForegroundColor Red + Write-StatusMessage "Error uninstalling module $($module.name): $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error } - Write-StatusMessage "- PowerShell modules uninstallation completed! Processed $moduleCount modules.`n" -ForegroundColor Green - return $true - } - catch { - Write-StatusMessage "Error uninstalling PowerShell modules: $_" -Verbosity Error - Write-StatusMessage $_.ScriptStackTrace -Verbosity Error - return $false } + Write-StatusMessage "- PowerShell modules uninstallation completed! Processed $moduleCount modules.`n" -ForegroundColor Green + return $true } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.Tests.ps1 index b2b3e3d..3498303 100644 --- a/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.Tests.ps1 @@ -3,9 +3,14 @@ BeforeAll { . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-OperatingSystem.ps1 . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Get-EnvironmentVariable.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\Get-PowershellModuleScopeMap.ps1 + + Mock Write-StatusMessage { } + if($PSVersionTable.PSVersion.Major -eq 5) { - $script:LocalModulePath = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\" - $script:AllUsersModulePath = "$env:ProgramFiles\WindowsPowerShell\Modules\" + $script:LocalModulePath = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules" + $script:AllUsersModulePath = "$env:ProgramFiles\WindowsPowerShell\Modules" Mock Get-EnvironmentVariable { Param( $Name @@ -18,8 +23,8 @@ BeforeAll { Mock Test-OperatingSystem { $true } } else { if($IsWindows) { - $script:LocalModulePath = "$env:USERPROFILE\Documents\PowerShell\Modules\" - $script:AllUsersModulePath = "$env:ProgramFiles\PowerShell\Modules\" + $script:LocalModulePath = "$env:USERPROFILE\Documents\PowerShell\Modules" + $script:AllUsersModulePath = "$env:ProgramFiles\PowerShell\Modules" Mock Get-EnvironmentVariable { Param( $Name @@ -33,8 +38,8 @@ BeforeAll { Mock Test-OperatingSystem { $true } } if($IsLinux) { - $script:LocalModulePath = "$env:HOME/.local/share/powershell/Modules/" - $script:AllUsersModulePath = "/usr/local/share/powershell/Modules/" + $script:LocalModulePath = "$env:HOME/.local/share/powershell/Modules" + $script:AllUsersModulePath = "/usr/local/share/powershell/Modules" Mock Get-EnvironmentVariable { Param( $Name @@ -48,8 +53,8 @@ BeforeAll { Mock Test-OperatingSystem { $false } } if($IsMacOS) { - $script:LocalModulePath = "$env:HOME/.local/share/powershell/Modules/" - $script:AllUsersModulePath = "/usr/local/share/powershell/Modules/" + $script:LocalModulePath = "$env:HOME/.local/share/powershell/Modules" + $script:AllUsersModulePath = "/usr/local/share/powershell/Modules" Mock Get-EnvironmentVariable { Param( $Name @@ -63,6 +68,12 @@ BeforeAll { Mock Test-OperatingSystem { $false } } } + + # Mock Get-PowershellModuleScopeMap to return appropriate paths + Mock Get-PowershellModuleScopeMap { @( + @{ Path = $script:LocalModulePath; Scope = "CurrentUser" }, + @{ Path = $script:AllUsersModulePath; Scope = "AllUsers" } + ) } } Describe "Test-PowershellModuleInstalled" { @@ -81,7 +92,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "posh-git" Version = "1.0.0" - Path = "$($script:LocalModulePath)posh-git" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)posh-git\posh-git.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "posh-git" @@ -96,7 +107,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "PSReadLine" Version = "2.2.6" - Path = "$($script:LocalModulePath)PSReadLine" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)PSReadLine\PSReadLine.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "PSReadLine" -Version "2.2.6" @@ -111,7 +122,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "PSReadLine" Version = "2.2.5" - Path = "$($script:LocalModulePath)PSReadLine" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)PSReadLine\PSReadLine.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "PSReadLine" -Version "2.2.6" @@ -126,7 +137,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "PowerShellGet" Version = "2.2.5" - Path = "$($script:AllUsersModulePath)PowerShellGet" + Path = "$($script:AllUsersModulePath)$([System.IO.Path]::DirectorySeparatorChar)PowerShellGet\PowerShellGet.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "PowerShellGet" -Scope "AllUsers" @@ -141,7 +152,7 @@ Describe "Test-PowershellModuleInstalled" { [PSCustomObject]@{ Name = "Az" Version = "9.0.1" - Path = "$($script:LocalModulePath)Az" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)Az\Az.psd1" } } $result = Test-PowershellModuleInstalled -ModuleName "Az" -Scope "CurrentUser" @@ -157,4 +168,52 @@ Describe "Test-PowershellModuleInstalled" { $result | Should -BeExactly ([InstalledState]::NotInstalled) } } + + Context "When Get-PowershellModuleScopeMap throws an exception" { + It "Should return NotInstalled and log error" { + Mock Get-PowershellModuleScopeMap { throw "Scope map error" } + $result = Test-PowershellModuleInstalled -ModuleName "Az" + $result | Should -BeExactly ([InstalledState]::NotInstalled) + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Failed to get PowerShell module scope map" -and $Verbosity -eq "Error" } + } + } + + Context "When Get-PowershellModuleScopeMap returns empty" { + It "Should return NotInstalled and log warning" { + Mock Get-PowershellModuleScopeMap { @() } + $result = Test-PowershellModuleInstalled -ModuleName "Az" + $result | Should -BeExactly ([InstalledState]::NotInstalled) + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "No PowerShell module install paths found" -and $Verbosity -eq "Warning" } + } + } + + Context "When module is installed in wrong scope" { + It "Should return Installed + MinimumVersionMet + RequiredVersionMet (without GlobalVersionMet)" { + Mock Get-Module { + [PSCustomObject]@{ + Name = "TestModule" + Version = "1.0.0" + Path = "$($script:LocalModulePath)$([System.IO.Path]::DirectorySeparatorChar)TestModule\TestModule.psd1" + } + } + $result = Test-PowershellModuleInstalled -ModuleName "TestModule" -Scope "AllUsers" + $expected = [InstalledState]::Installed + [InstalledState]::MinimumVersionMet + [InstalledState]::RequiredVersionMet + $result | Should -BeExactly $expected + } + } + + Context "When module is installed with version and scope both matching" { + It "Should return full Pass state" { + Mock Get-Module { + [PSCustomObject]@{ + Name = "FullTest" + Version = "3.1.4" + Path = "$($script:AllUsersModulePath)$([System.IO.Path]::DirectorySeparatorChar)FullTest\FullTest.psd1" + } + } + $result = Test-PowershellModuleInstalled -ModuleName "FullTest" -Version "3.1.4" -Scope "AllUsers" + $expected = [InstalledState]::Pass + $result | Should -BeExactly $expected + } + } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.ps1 b/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.ps1 index 2d90d23..d6a140f 100644 --- a/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.ps1 +++ b/DevSetup/Private/Providers/Powershell/Test-PowershellModuleInstalled.ps1 @@ -90,21 +90,18 @@ Function Test-PowershellModuleInstalled { [string]$Scope ) - if((Test-OperatingSystem -Windows)) { - $SearchPath = (Get-EnvironmentVariable USERPROFILE) - } else { - $SearchPath = (Get-EnvironmentVariable HOME) + try { + $InstallPaths = Get-PowershellModuleScopeMap + } catch { + Write-StatusMessage "Failed to get PowerShell module scope map: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return [InstalledState]::NotInstalled } - $InstallPaths = @( - (Get-EnvironmentVariable PSModulePath) -split ([System.IO.Path]::PathSeparator) | ForEach-Object { - if($_ -match [regex]::Escape("$SearchPath")) { - @{ Path = $_; Scope = "CurrentUser" } - } else { - @{ Path = $_; Scope = "AllUsers" } - } - } - ) + if(-not $InstallPaths -or $InstallPaths.Count -eq 0) { + Write-StatusMessage "No PowerShell module install paths found." -Verbosity Warning + return [InstalledState]::NotInstalled + } [InstalledState]$installedState = [InstalledState]::NotInstalled diff --git a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.Tests.ps1 index 3971c26..4266cc0 100644 --- a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.Tests.ps1 @@ -1,108 +1,352 @@ BeforeAll { - . (Join-Path $PSScriptRoot "Uninstall-PowershellModule.ps1") - . (Join-Path $PSScriptRoot "Test-PowershellModuleInstalled.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1") - . (Join-Path $PSScriptRoot "..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1") - Mock Test-RunningAsAdmin { return $true } - Mock Write-StatusMessage { } + . $PSScriptRoot\Uninstall-PowershellModule.ps1 + . $PSScriptRoot\Test-PowershellModuleInstalled.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Test-RunningAsAdmin.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Enums\InstalledState.ps1 + . $PSScriptRoot\..\..\..\..\DevSetup\Private\Utils\Write-StatusMessage.ps1 } Describe "Uninstall-PowershellModule" { + BeforeEach { + Mock Test-RunningAsAdmin { return $true } + Mock Write-StatusMessage { } + Mock Remove-Module { } + Mock Uninstall-Module { } + } + Context "When module is not installed" { - It "Should return true and warn" { + It "Should return true and log warning" { Mock Test-PowershellModuleInstalled { return [InstalledState]::NotInstalled } - $result = Uninstall-PowershellModule -ModuleName "notfound" + $result = Uninstall-PowershellModule -ModuleName "NonExistentModule" $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "NonExistentModule.*is not installed" + } -Times 1 } } - Context "When module is installed for AllUsers but not running as admin" { - It "Should return false and warn" { + Context "When initial check throws exception" { + It "Should return false and log error" { + Mock Test-PowershellModuleInstalled { throw "Check failed" } + $result = Uninstall-PowershellModule -ModuleName "ErrorModule" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error checking installation status" + } -Times 1 + } + } + + Context "When scope check throws exception" { + It "Should return false and log error" { $callCount = 0 - Mock Test-PowershellModuleInstalled -MockWith { - param() - $callCount++ - if ($callCount -eq 1) { return [InstalledState]::Installed } - if ($callCount -eq 2) { return [InstalledState]::Pass } - return [InstalledState]::NotInstalled + Mock Test-PowershellModuleInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + throw "Scope check failed" } + $result = Uninstall-PowershellModule -ModuleName "ScopeError" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error checking installation scope" + } -Times 1 + } + } + + Context "When AllUsers module but not admin" { + It "Should return false and warn" { + # Use parameter-based mocking instead of call counting + Mock Test-PowershellModuleInstalled { return [InstalledState]::Installed } -ParameterFilter { -not $PSBoundParameters.ContainsKey('Scope') } + Mock Test-PowershellModuleInstalled { return [InstalledState]::Pass } -ParameterFilter { $Scope -eq 'AllUsers' } Mock Test-RunningAsAdmin { return $false } - $result = Uninstall-PowershellModule -ModuleName "Az" + $result = Uninstall-PowershellModule -ModuleName "AdminModule" $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "installed for AllUsers but current session is not elevated" + } -Times 1 } } - Context "When module is installed and uninstall succeeds" { - It "Should remove and uninstall the module, returning true" { + Context "When uninstall succeeds" { + It "Should return true" { $script:callCount = 0 - Mock Test-PowershellModuleInstalled -MockWith { - param() + Mock Test-PowershellModuleInstalled { $script:callCount++ if ($script:callCount -eq 1) { return [InstalledState]::Installed } if ($script:callCount -eq 2) { return [InstalledState]::Installed } if ($script:callCount -eq 3) { return [InstalledState]::NotInstalled } return [InstalledState]::NotInstalled } - $script:removeCalled = $false - $script:uninstallCalled = $false - Mock Remove-Module -MockWith { - param() - $script:removeCalled = $true - } - Mock Uninstall-Module -MockWith { - param() - $script:uninstallCalled = $true - } - $result = Uninstall-PowershellModule -ModuleName "posh-git" - $removeCalled | Should -Be $true - $uninstallCalled | Should -Be $true + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false $result | Should -Be $true + Assert-MockCalled Remove-Module -Times 1 + Assert-MockCalled Uninstall-Module -Times 1 } } - Context "When uninstall fails with exception" { - It "Should return false and write error" { + Context "When Remove-Module fails" { + It "Should continue and succeed" { $script:callCount = 0 - Mock Test-PowershellModuleInstalled -MockWith { - param() + Mock Test-PowershellModuleInstalled { $script:callCount++ if ($script:callCount -eq 1) { return [InstalledState]::Installed } if ($script:callCount -eq 2) { return [InstalledState]::Installed } + if ($script:callCount -eq 3) { return [InstalledState]::NotInstalled } return [InstalledState]::NotInstalled } - Mock Remove-Module -MockWith { - param() + Mock Remove-Module { throw "Remove failed" } + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "Failed to remove module.*from current session" + } -Times 1 + Assert-MockCalled Uninstall-Module -Times 1 + } + } + + Context "When Uninstall-Module fails" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + if ($script:callCount -eq 2) { return [InstalledState]::Installed } + return [InstalledState]::NotInstalled } - Mock Uninstall-Module -MockWith { - param() - throw "Uninstall failed" + Mock Uninstall-Module { throw "Uninstall failed" } + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error during Uninstall-Module" + } -Times 1 + } + } + + Context "When final verification fails" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled { + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + if ($script:callCount -eq 2) { return [InstalledState]::Installed } + if ($script:callCount -eq 3) { throw "Verify failed" } + return [InstalledState]::NotInstalled } - $result = Uninstall-PowershellModule -ModuleName "PSReadLine" + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error verifying uninstallation" + } -Times 1 } } - Context "When module is installed but still present after uninstall" { + Context "When module still installed after uninstall" { It "Should return false" { $script:callCount = 0 - Mock Test-PowershellModuleInstalled -MockWith { - param() + Mock Test-PowershellModuleInstalled { $script:callCount++ if ($script:callCount -eq 1) { return [InstalledState]::Installed } if ($script:callCount -eq 2) { return [InstalledState]::Installed } if ($script:callCount -eq 3) { return [InstalledState]::Installed } + return [InstalledState]::Installed + } + $result = Uninstall-PowershellModule -ModuleName "TestModule" -Confirm:$false + $result | Should -Be $false + } + } + + Context "When using WhatIf" { + It "Should return true and not uninstall" { + Mock Test-PowershellModuleInstalled { return [InstalledState]::Installed } + $result = Uninstall-PowershellModule -ModuleName "TestModule" -WhatIf + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "was cancelled by user" + } -Times 1 + Assert-MockCalled Remove-Module -Times 0 + Assert-MockCalled Uninstall-Module -Times 0 + } + } + + Context "When scope installation check throws exception" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + if ($script:callCount -eq 2) { throw "Scope check failed" } + return [InstalledState]::NotInstalled + } + $result = Uninstall-PowershellModule -ModuleName "ScopeErrorModule" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error checking installation scope.*ScopeErrorModule.*Scope check failed" + } -Times 1 + } + } + + Context "When module is installed for AllUsers but not running as admin" { + It "Should return false and warn about privileges" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } + if ($script:callCount -eq 2) { return [InstalledState]::Pass } # Has Pass flag for AllUsers return [InstalledState]::NotInstalled } - Mock Remove-Module -MockWith { - param() + Mock Test-RunningAsAdmin { return $false } + $result = Uninstall-PowershellModule -ModuleName "AdminRequiredModule" + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "AdminRequiredModule.*installed for AllUsers but current session is not elevated" + } -Times 1 + } + } + + Context "When module uninstall succeeds" { + It "Should return true and call Remove-Module and Uninstall-Module" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check (CurrentUser) + if ($script:callCount -eq 3) { return [InstalledState]::NotInstalled } # Final verification + return [InstalledState]::NotInstalled } - Mock Uninstall-Module -MockWith { - param() + $result = Uninstall-PowershellModule -ModuleName "SuccessModule" -Confirm:$false + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Debug" -and $Message -match "Uninstalling PowerShell module \'SuccessModule\'..." + } -Times 1 + Assert-MockCalled Remove-Module -ParameterFilter { + $Name -eq "SuccessModule" -and $Force -eq $true + } -Times 1 + Assert-MockCalled Uninstall-Module -ParameterFilter { + $Name -eq "SuccessModule" -and $Force -eq $true + } -Times 1 + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Debug" -and $Message -match "PowerShell module \'SuccessModule\' uninstalled successfully." + } -Times 1 + } + } + + Context "When Remove-Module throws exception" { + It "Should log warning and continue with Uninstall-Module" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + if ($script:callCount -eq 3) { return [InstalledState]::NotInstalled } # Final verification + return [InstalledState]::NotInstalled } - $result = Uninstall-PowershellModule -ModuleName "PowerShellGet" + Mock Remove-Module { throw "Remove-Module failed" } + $result = Uninstall-PowershellModule -ModuleName "RemoveErrorModule" -Confirm:$false + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "Failed to remove module 'RemoveErrorModule' from current session.*Remove-Module failed" + } -Times 1 + Assert-MockCalled Uninstall-Module -ParameterFilter { + $Name -eq "RemoveErrorModule" -and $Force -eq $true + } -Times 1 + } + } + + Context "When Uninstall-Module throws exception" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + return [InstalledState]::NotInstalled + } + Mock Uninstall-Module { throw "Uninstall-Module failed" } + $result = Uninstall-PowershellModule -ModuleName "UninstallErrorModule" -Confirm:$false $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error during Uninstall-Module for 'UninstallErrorModule'.*Uninstall-Module failed" + } -Times 1 + } + } + + Context "When final verification throws exception" { + It "Should return false and log error" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + if ($script:callCount -eq 3) { throw "Final verification failed" } # Final verification throws + return [InstalledState]::NotInstalled + } + $result = Uninstall-PowershellModule -ModuleName "VerifyErrorModule" -Confirm:$false + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "Error verifying uninstallation.*VerifyErrorModule.*Final verification failed" + } -Times 1 + } + } + + Context "When module still shows as installed after uninstall" { + It "Should return false" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + if ($script:callCount -eq 3) { return [InstalledState]::Installed } # Final verification - still installed + return [InstalledState]::NotInstalled + } + $result = Uninstall-PowershellModule -ModuleName "PersistentModule" -Confirm:$false + $result | Should -Be $false + Assert-MockCalled Uninstall-Module -ParameterFilter { + $Name -eq "PersistentModule" + } -Times 1 + } + } + + Context "When using WhatIf parameter" { + It "Should return true and not actually uninstall" { + $script:callCount = 0 + Mock Test-PowershellModuleInstalled -MockWith { + param($ModuleName, $Scope) + $script:callCount++ + if ($script:callCount -eq 1) { return [InstalledState]::Installed } # Initial check + if ($script:callCount -eq 2) { return [InstalledState]::Installed } # Scope check + return [InstalledState]::NotInstalled + } + $result = Uninstall-PowershellModule -ModuleName "WhatIfModule" -WhatIf + $result | Should -Be $true # ShouldProcess returns false for WhatIf, so else branch returns true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "Uninstallation of PowerShell module 'WhatIfModule' was cancelled by user" + } -Times 1 + Assert-MockCalled Remove-Module -Times 0 + Assert-MockCalled Uninstall-Module -Times 0 + } + } + + Context "When user cancels ShouldProcess confirmation" { + It "Should return true and log cancellation message" { + # This test demonstrates the new behavior where cancellation returns true + Mock Test-PowershellModuleInstalled { return [InstalledState]::Installed } -ParameterFilter { -not $PSBoundParameters.ContainsKey('Scope') } + Mock Test-PowershellModuleInstalled { return [InstalledState]::Installed } -ParameterFilter { $Scope -eq 'AllUsers' } + + # Test using WhatIf to simulate ShouldProcess returning false + $result = Uninstall-PowershellModule -ModuleName "CancelledModule" -WhatIf + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Warning" -and $Message -match "Uninstallation of PowerShell module 'CancelledModule' was cancelled by user" + } -Times 1 + Assert-MockCalled Remove-Module -Times 0 + Assert-MockCalled Uninstall-Module -Times 0 } } } \ No newline at end of file diff --git a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.ps1 b/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.ps1 index 1773d83..8df9066 100644 --- a/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.ps1 +++ b/DevSetup/Private/Providers/Powershell/Uninstall-PowershellModule.ps1 @@ -70,30 +70,57 @@ Function Uninstall-PowershellModule { [String] $ModuleName ) - $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName + try { + $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName + } catch { + Write-StatusMessage "Error checking installation status of PowerShell module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } if ($installedState -eq [InstalledState]::NotInstalled) { Write-StatusMessage "PowerShell module '$ModuleName' is not installed. No action taken." -Verbosity Warning return $true } - $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName -Scope 'AllUsers' + try { + $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName -Scope 'AllUsers' + } catch { + Write-StatusMessage "Error checking installation scope of PowerShell module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } if ($installedState.HasFlag([InstalledState]::Pass) -and (-not (Test-RunningAsAdmin))) { Write-StatusMessage "PowerShell module '$ModuleName' is installed for AllUsers but current session is not elevated. Cannot uninstall." -Verbosity Warning return $false } - try { - Write-StatusMessage "Uninstalling PowerShell module '$ModuleName'..." -Verbosity Debug - if ($PSCmdlet.ShouldProcess($ModuleName, "Uninstall-Module")) { + Write-StatusMessage "Uninstalling PowerShell module '$ModuleName'..." -Verbosity Debug + if ($PSCmdlet.ShouldProcess($ModuleName, "Uninstall-Module")) { + try { Remove-Module -Name $ModuleName -Force -ErrorAction SilentlyContinue + } catch { + Write-StatusMessage "Warning: Failed to remove module '$ModuleName' from current session: $_" -Verbosity Warning + } + try { Uninstall-Module -Name $ModuleName -Force -ErrorAction Stop + } catch { + Write-StatusMessage "Error during Uninstall-Module for '$ModuleName': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false } - Write-StatusMessage "PowerShell module '$ModuleName' uninstalled successfully." -Verbosity Debug + } else { + Write-StatusMessage "Uninstallation of PowerShell module '$ModuleName' was cancelled by user." -Verbosity Warning + return $true + } + + Write-StatusMessage "PowerShell module '$ModuleName' uninstalled successfully." -Verbosity Debug + + try { $installedState = Test-PowershellModuleInstalled -ModuleName $ModuleName - return ($installedState -eq [InstalledState]::NotInstalled) } catch { - Write-StatusMessage "Failed to uninstall PowerShell module '$ModuleName': $_" -Verbosity Error + Write-StatusMessage "Error verifying uninstallation of PowerShell module '$ModuleName': $_" -Verbosity Error Write-StatusMessage $_.ScriptStackTrace -Verbosity Error return $false } + return ($installedState -eq [InstalledState]::NotInstalled) } \ No newline at end of file