From 88ed404f1549a8a949c91823bb016fedb9e3b7db Mon Sep 17 00:00:00 2001 From: kormic911 Date: Sat, 13 Sep 2025 17:58:51 -0500 Subject: [PATCH 1/3] Updated powershell provider to be in parity with the other modules that use the new structure Added parity code to ensure dryrun works properly with powershell modules Refactored code to be more in line with our guidelines and removed code that is no longer being used, making the functions simpler in general Updated test cases to maintain 100% test coverage --- .../Commands/Uninstall-DevSetupEnv.ps1 | 2 +- DevSetup/Private/Enums/TaskState.ps1 | 9 - .../Get-PowershellModuleScopeMap.Tests.ps1 | 394 ++++++++++++++++++ .../Get-PowershellModuleScopeMap.ps1 | 20 + .../Install-PowershellModule.Tests.ps1 | 51 +++ .../Powershell/Install-PowershellModule.ps1 | 85 ++-- .../Invoke-PowershellModulesExport.Tests.ps1 | 138 +++++- .../Invoke-PowershellModulesExport.ps1 | 279 ++++++------- .../Invoke-PowershellModulesInstall.Tests.ps1 | 177 +++++--- .../Invoke-PowershellModulesInstall.ps1 | 111 +++-- ...nvoke-PowershellModulesUninstall.Tests.ps1 | 150 ++++--- .../Invoke-PowershellModulesUninstall.ps1 | 102 ++--- .../Test-PowershellModuleInstalled.Tests.ps1 | 85 +++- .../Test-PowershellModuleInstalled.ps1 | 23 +- .../Uninstall-PowershellModule.Tests.ps1 | 350 +++++++++++++--- .../Powershell/Uninstall-PowershellModule.ps1 | 43 +- 16 files changed, 1507 insertions(+), 512 deletions(-) delete mode 100644 DevSetup/Private/Enums/TaskState.ps1 create mode 100644 DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.Tests.ps1 create mode 100644 DevSetup/Private/Providers/Powershell/Get-PowershellModuleScopeMap.ps1 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..3b20899 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 @@ -6,14 +6,20 @@ 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" } + @{ Name = "ModuleA"; Version = [version]"1.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleA" }, + @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" } ) } Mock Get-DevSetupManifest { @{ RequiredModules = @("ModuleA") } } + Mock Get-PowershellModuleScopeMap { @( + @{ Path = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules"; Scope = "CurrentUser" }, + @{ Path = "$env:ProgramFiles\WindowsPowerShell\Modules"; Scope = "AllUsers" } + ) } 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 Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(); scope = "CurrentUser" } } } } } Mock Update-DevSetupEnvFile { } Mock Write-Host { } Mock Write-Warning { } @@ -34,6 +40,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 { @() } @@ -46,8 +97,8 @@ Describe "Invoke-PowershellModulesExport" { Context "When core dependency modules are present" { It "Should skip core dependency 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 = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleA" }, + @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" } ) } Mock Get-DevSetupManifest { @{ RequiredModules = @("ModuleA") } } Invoke-PowershellModulesExport -Config "test.yaml" @@ -56,6 +107,18 @@ Describe "Invoke-PowershellModulesExport" { } } + Context "When core dependency modules are hashtable format" { + It "Should skip hashtable format core dependency modules" { + Mock Get-InstalledModule { @( + @{ Name = "ModuleA"; Version = [version]"1.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleA" }, + @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\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 +129,8 @@ 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" }) } + 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 = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" }) } Invoke-PowershellModulesExport -Config "test.yaml" Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Updating module: ModuleB" } } @@ -75,8 +138,8 @@ 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" }) } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(@{ name = "ModuleB"; scope = "CurrentUser" }); scope = "CurrentUser" } } } } } + Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" }) } Invoke-PowershellModulesExport -Config "test.yaml" Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { $Message -match "Updating module version: ModuleB" } } @@ -84,10 +147,49 @@ 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" }) } + 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 = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\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" } + 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" { + 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 = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\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" { + Mock Get-InstalledModule { @(@{ Name = "UnknownModule"; Version = [version]"1.0.0"; InstalledLocation = "C:\Some\Unknown\Path\UnknownModule" }) } + Mock Get-PowershellModuleScopeMap { @( + @{ Path = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules"; Scope = "CurrentUser" }, + @{ Path = "$env:ProgramFiles\WindowsPowerShell\Modules"; 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" } } } @@ -100,13 +202,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 +213,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..617aa7b 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 @@ -71,181 +71,180 @@ 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" - } - } - - 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 + 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 = $YamlData.devsetup.dependencies.powershell.scope + $InstallPaths | ForEach-Object { + if ($module.InstalledLocation -like "$($_.Path)$([System.IO.Path]::DirectorySeparatorChar)*") { + $script:moduleScope = $_.Scope } } - Write-StatusMessage " - Found $($powershellModules.Count) PowerShell modules in CurrentUser or AllUsers scope (excluding core dependencies)" -Verbosity Debug + 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 + } + } - # 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 From ffac4c8ad3a8bd0f0c67460773971176641a3c19 Mon Sep 17 00:00:00 2001 From: kormic911 Date: Sat, 13 Sep 2025 18:38:25 -0500 Subject: [PATCH 2/3] Fixing a potential issue and correcting the test cases to get to 100% coverage --- .../Invoke-PowershellModulesExport.Tests.ps1 | 81 ++++++++++++++----- .../Invoke-PowershellModulesExport.ps1 | 2 +- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 index 3b20899..911deac 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.Tests.ps1 @@ -9,16 +9,27 @@ BeforeAll { . (Join-Path $PSScriptRoot "Get-PowershellModuleScopeMap.ps1") Mock Test-RunningAsAdmin { $true } - Mock Get-InstalledModule { @( - @{ Name = "ModuleA"; Version = [version]"1.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleA" }, - @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" } - ) } + 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-PowershellModuleScopeMap { @( - @{ Path = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules"; Scope = "CurrentUser" }, - @{ Path = "$env:ProgramFiles\WindowsPowerShell\Modules"; Scope = "AllUsers" } - ) } - Mock Get-Module { param($Name) @{ Name = $Name; ModuleBase = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\$Name"; Version = [version]"1.0.0" } } + 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 { } @@ -96,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"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleA" }, - @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" } + @{ 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" @@ -109,9 +121,10 @@ 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 = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleA" }, - @{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" } + @{ 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" @@ -129,8 +142,9 @@ Describe "Invoke-PowershellModulesExport" { Context "When module version changes" { It "Should update the module version in the config" { + $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 = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" }) } + 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" } } @@ -138,8 +152,9 @@ Describe "Invoke-PowershellModulesExport" { Context "When module exists but has no version" { It "Should add minimumVersion to the module" { + $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 = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" }) } + 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" } } @@ -147,6 +162,7 @@ Describe "Invoke-PowershellModulesExport" { Context "When module is unchanged" { It "Should skip updating the module" { + $userPath = Join-Path $TestDrive "Users" "TestUser" "Documents" "WindowsPowerShell" "Modules" Mock Read-DevSetupEnvFile { @{ devsetup = @{ @@ -163,7 +179,7 @@ Describe "Invoke-PowershellModulesExport" { } } } - Mock Get-InstalledModule { @(@{ Name = "ModuleB"; Version = [version]"2.0.0"; InstalledLocation = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" }) } + 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" } @@ -172,8 +188,9 @@ Describe "Invoke-PowershellModulesExport" { 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 = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules\ModuleB" }) } + 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\)" } } @@ -181,10 +198,14 @@ Describe "Invoke-PowershellModulesExport" { Context "When module has unknown scope" { It "Should skip module with unknown installation location" { - Mock Get-InstalledModule { @(@{ Name = "UnknownModule"; Version = [version]"1.0.0"; InstalledLocation = "C:\Some\Unknown\Path\UnknownModule" }) } + $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 = "$env:USERPROFILE\Documents\WindowsPowerShell\Modules"; Scope = "CurrentUser" }, - @{ Path = "$env:ProgramFiles\WindowsPowerShell\Modules"; Scope = "AllUsers" } + @{ Path = $userPath; Scope = "CurrentUser" }, + @{ Path = $systemPath; Scope = "AllUsers" } ) } Mock Get-DevSetupManifest { @{ RequiredModules = @() } } Mock Read-DevSetupEnvFile { @{ devsetup = @{ dependencies = @{ powershell = @{ modules = @(); scope = "UnknownScope" } } } } } @@ -193,6 +214,26 @@ Describe "Invoke-PowershellModulesExport" { } } + 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 "Found module: SystemModule.*scope: AllUsers" } + } + } + Context "When DryRun is used" { It "Should call Update-DevSetupEnvFile with -WhatIf and not write to file" { $result = Invoke-PowershellModulesExport -Config "test.yaml" -DryRun diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 index 617aa7b..c960f06 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 @@ -161,7 +161,7 @@ Function Invoke-PowershellModulesExport { $moduleScope = $YamlData.devsetup.dependencies.powershell.scope $InstallPaths | ForEach-Object { if ($module.InstalledLocation -like "$($_.Path)$([System.IO.Path]::DirectorySeparatorChar)*") { - $script:moduleScope = $_.Scope + $moduleScope = $_.Scope } } From 7489642a6260e462cd60c04d5e43bc60010aa68e Mon Sep 17 00:00:00 2001 From: kormic911 Date: Sat, 13 Sep 2025 18:46:20 -0500 Subject: [PATCH 3/3] Updating $moduleScope to just use the installed modules scope as the source of truth --- .../Powershell/Invoke-PowershellModulesExport.ps1 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 index c960f06..39b4a34 100644 --- a/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 +++ b/DevSetup/Private/Providers/Powershell/Invoke-PowershellModulesExport.ps1 @@ -158,12 +158,11 @@ Function Invoke-PowershellModulesExport { continue } - $moduleScope = $YamlData.devsetup.dependencies.powershell.scope - $InstallPaths | ForEach-Object { + $moduleScope = ($InstallPaths | ForEach-Object { if ($module.InstalledLocation -like "$($_.Path)$([System.IO.Path]::DirectorySeparatorChar)*") { - $moduleScope = $_.Scope + $_.Scope } - } + }) if ($moduleScope -eq "CurrentUser" -or $moduleScope -eq "AllUsers") { Write-StatusMessage "Found module: $($module.Name) (version: $($module.Version), scope: $moduleScope)" -Verbosity Debug