From 08b01d8c7d94b84a1e78efcc63b073440735c5f4 Mon Sep 17 00:00:00 2001 From: kormic911 Date: Mon, 15 Sep 2025 00:32:58 -0500 Subject: [PATCH] Adding self updater feat/self updater - Allows for easy upgrade to newer versions of devsetup. --- .../Commands/Update-DevSetup.Tests.ps1 | 88 +++ DevSetup/Private/Commands/Update-DevSetup.ps1 | 29 +- .../Expand-DevSetupUpdateArchive.Tests.ps1 | 151 ++++ .../Updater/Expand-DevSetupUpdateArchive.ps1 | 29 + .../Get-DevSetupModuleInstallPath.Tests.ps1 | 62 ++ .../Updater/Get-DevSetupModuleInstallPath.ps1 | 16 + .../Updater/Get-DevSetupUpdateUri.Tests.ps1 | 179 +++++ .../Private/Updater/Get-DevSetupUpdateUri.ps1 | 51 ++ .../Get-DownloadedDevSetupManifest.Tests.ps1 | 95 +++ .../Get-DownloadedDevSetupManifest.ps1 | 37 + .../Updater/Install-DevSetupModule.Tests.ps1 | 208 ++++++ .../Updater/Install-DevSetupModule.ps1 | 63 ++ .../Install-RequiredDevSetupModules.Tests.ps1 | 124 ++++ .../Install-RequiredDevSetupModules.ps1 | 31 + .../Invoke-DevSetupDownloadUpdate.Tests.ps1 | 117 +++ .../Updater/Invoke-DevSetupDownloadUpdate.ps1 | 31 + .../Start-DevSetupSelfUpdate.Tests.ps1 | 673 ++++++++++++++++++ .../Updater/Start-DevSetupSelfUpdate.ps1 | 140 ++++ .../Uninstall-DevSetupModule.Tests.ps1 | 72 ++ .../Updater/Uninstall-DevSetupModule.ps1 | 20 + .../Utils/Assert-DevSetupEnvValid.Tests.ps1 | 39 + .../Private/Utils/Assert-DevSetupEnvValid.ps1 | 4 - .../Private/Utils/Format-CenterText.Tests.ps1 | 284 ++++++++ DevSetup/Private/Utils/Format-CenterText.ps1 | 17 + .../Private/Utils/Format-LeftText.Tests.ps1 | 368 ++++++++++ DevSetup/Private/Utils/Format-LeftText.ps1 | 14 + .../Utils/Format-PrettyTable.Tests.ps1 | 104 +++ .../Private/Utils/Format-RightText.Tests.ps1 | 371 ++++++++++ DevSetup/Private/Utils/Format-RightText.ps1 | 12 + .../Utils/Start-DevSetupSelfUpdate.ps1 | 22 - .../Private/Utils/Write-NewConfig.Tests.ps1 | 29 + DevSetup/Public/Use-DevSetup.Tests.ps1 | 76 +- DevSetup/Public/Use-DevSetup.ps1 | 2 +- generateCoverageReport.ps1 | 2 +- preCommit.ps1 | 62 ++ runTests.ps1 | 6 +- 36 files changed, 3561 insertions(+), 67 deletions(-) create mode 100644 DevSetup/Private/Commands/Update-DevSetup.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.ps1 create mode 100644 DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.ps1 create mode 100644 DevSetup/Private/Updater/Get-DevSetupUpdateUri.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Get-DevSetupUpdateUri.ps1 create mode 100644 DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.ps1 create mode 100644 DevSetup/Private/Updater/Install-DevSetupModule.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Install-DevSetupModule.ps1 create mode 100644 DevSetup/Private/Updater/Install-RequiredDevSetupModules.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Install-RequiredDevSetupModules.ps1 create mode 100644 DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.ps1 create mode 100644 DevSetup/Private/Updater/Start-DevSetupSelfUpdate.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Start-DevSetupSelfUpdate.ps1 create mode 100644 DevSetup/Private/Updater/Uninstall-DevSetupModule.Tests.ps1 create mode 100644 DevSetup/Private/Updater/Uninstall-DevSetupModule.ps1 create mode 100644 DevSetup/Private/Utils/Format-CenterText.Tests.ps1 create mode 100644 DevSetup/Private/Utils/Format-CenterText.ps1 create mode 100644 DevSetup/Private/Utils/Format-LeftText.Tests.ps1 create mode 100644 DevSetup/Private/Utils/Format-LeftText.ps1 create mode 100644 DevSetup/Private/Utils/Format-RightText.Tests.ps1 create mode 100644 DevSetup/Private/Utils/Format-RightText.ps1 delete mode 100644 DevSetup/Private/Utils/Start-DevSetupSelfUpdate.ps1 create mode 100644 preCommit.ps1 diff --git a/DevSetup/Private/Commands/Update-DevSetup.Tests.ps1 b/DevSetup/Private/Commands/Update-DevSetup.Tests.ps1 new file mode 100644 index 0000000..fb739e4 --- /dev/null +++ b/DevSetup/Private/Commands/Update-DevSetup.Tests.ps1 @@ -0,0 +1,88 @@ +BeforeAll { + # Source the function under test + . $PSScriptRoot\Update-DevSetup.ps1 + . $PSScriptRoot\..\Updater\Start-DevSetupSelfUpdate.ps1 + Mock Start-DevSetupSelfUpdate { } +} + +Describe "Update-DevSetup" { + + Context "When Main parameter is specified" { + It "Should call Start-DevSetupSelfUpdate with Main parameter" { + Update-DevSetup -Main + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It -ParameterFilter { $Main -eq $true } + } + } + + Context "When Develop parameter is specified" { + It "Should call Start-DevSetupSelfUpdate with Develop parameter" { + Update-DevSetup -Develop + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It -ParameterFilter { $Develop -eq $true } + } + } + + Context "When Version parameter is specified" { + It "Should call Start-DevSetupSelfUpdate with Version parameter" { + Update-DevSetup -Version "1.0.0" + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It -ParameterFilter { $Version -eq "1.0.0" } + } + } + + Context "When no parameters are specified (default)" { + It "Should call Start-DevSetupSelfUpdate without parameters (letting it use its own defaults)" { + Update-DevSetup + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It -ParameterFilter { $PSBoundParameters.Count -eq 0 } + } + } + + Context "Parameter set validation" { + It "Should allow Main parameter alone" { + { Update-DevSetup -Main } | Should -Not -Throw + } + + It "Should allow Develop parameter alone" { + { Update-DevSetup -Develop } | Should -Not -Throw + } + + It "Should allow Version parameter alone" { + { Update-DevSetup -Version "2.0.0" } | Should -Not -Throw + } + + It "Should not allow Main and Develop together" { + { Update-DevSetup -Main -Develop } | Should -Throw + } + + It "Should not allow Main and Version together" { + { Update-DevSetup -Main -Version "1.0.0" } | Should -Throw + } + + It "Should not allow Develop and Version together" { + { Update-DevSetup -Develop -Version "1.0.0" } | Should -Throw + } + } + + Context "PSBoundParameters forwarding" { + It "Should forward all parameters using splatting" { + Mock Start-DevSetupSelfUpdate { } -ParameterFilter { $PSBoundParameters.Count -gt 0 } + Update-DevSetup -Version "test" + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + Update-DevSetup -Main + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It + } + + It "Should work on Linux" { + Update-DevSetup -Develop + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It + } + + It "Should work on macOS" { + Update-DevSetup -Version "1.0.0" + Assert-MockCalled Start-DevSetupSelfUpdate -Exactly 1 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Commands/Update-DevSetup.ps1 b/DevSetup/Private/Commands/Update-DevSetup.ps1 index d52ae68..c6fa15d 100644 --- a/DevSetup/Private/Commands/Update-DevSetup.ps1 +++ b/DevSetup/Private/Commands/Update-DevSetup.ps1 @@ -1,30 +1,13 @@ Function Update-DevSetup { - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName="ReleaseInstall")] Param( - [Parameter(Mandatory=$true, ParameterSetName="Main")] + [Parameter(Mandatory=$true, ParameterSetName="MainWebInstall")] [switch]$Main, - [Parameter(Mandatory=$true, ParameterSetName="Develop")] + [Parameter(Mandatory=$true, ParameterSetName="DevelopWebInstall")] [switch]$Develop, - [Parameter(Mandatory=$true, ParameterSetName="Version")] - [string]$Version, - [Parameter(Mandatory=$true, ParameterSetName="Latest")] - [switch]$Latest + [Parameter(Mandatory=$false, ParameterSetName="ReleaseInstall")] + [string]$Version = "latest" ) - $RemoteVersion = Get-DevSetupVersion -Remote - $LocalVersion = Get-DevSetupVersion -Local - if($RemoteVersion -gt $LocalVersion) { - Write-Host "A new version of DevSetup is available: $RemoteVersion (current version: $LocalVersion)" -ForegroundColor Yellow - } elseif ($RemoteVersion -eq $LocalVersion) { - Write-Host "You are already running the latest version of DevSetup: $LocalVersion" -ForegroundColor Green - return - } else { - Write-Host "You are running a newer version of DevSetup ($LocalVersion) than the latest release ($RemoteVersion)" -ForegroundColor Yellow - return - } - Write-Host "" - Write-Host "- Updating list of available environments..." -ForegroundColor Cyan - Optimize-DevSetupEnvs | Out-Null - Write-Host "- Available environments updated successfully" -ForegroundColor Green - Write-Host "" + Start-DevSetupSelfUpdate @PSBoundParameters } \ No newline at end of file diff --git a/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.Tests.ps1 b/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.Tests.ps1 new file mode 100644 index 0000000..56f9d2a --- /dev/null +++ b/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.Tests.ps1 @@ -0,0 +1,151 @@ +BeforeAll { + $global:LASTEXITCODE = 0 + Function Expand-Archive { + param ( + [string]$Path, + [string]$DestinationPath, + [switch]$Force + ) + } + + Mock Expand-Archive { + switch($Path) { + (Join-Path $TestDrive "test.zip") { + # Simulate successful expansion + #Write-Output "test.zip expanded successfully" + $global:LASTEXITCODE = 0 + return + } + (Join-Path $TestDrive "bad.zip") { + #Write-Output "bad.zip encountered an error" + # Simulate failed expansion + throw "Simulated bad zip" + return + } + (Join-Path $TestDrive "testdest.zip") { + switch($DestinationPath) { + (Join-Path $TestDrive "extracted") { + #Write-Output "testdest.zip expanded successfully" + # Simulate successful extraction + $global:LASTEXITCODE = 0 + return + } + (Join-Path $TestDrive "badextract") { + #Write-Output "testdest.zip encountered an error" + # Simulate failed extraction + throw "Simulated bad destination" + return + } + default { + #Write-Output "Invalid destination: $DestinationPath" + # Simulate invalid destination + $global:LASTEXITCODE = 1 + throw "Invalid destination: $DestinationPath" + } + } + } + default { + Write-Error "File not found: $Path" + # Simulate file not found + $global:LASTEXITCODE = 1 + throw "File not found: $Path" + } + } + # Simulate successful expansion + $global:LASTEXITCODE = 1 + } + . $PSScriptRoot\Expand-DevSetupUpdateArchive.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 +} + +Describe "Expand-DevSetupUpdateArchive" { + + Context "When the archive file does not exist" { + It "Should return false and log an error" { + Mock Write-StatusMessage { } + $Archive = (Join-Path $TestDrive "nonexistent.zip") + $result = Expand-DevSetupUpdateArchive -Path $Archive -DestinationPath (Join-Path $TestDrive "temp") + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { + $Message -match "Archive file not found at path: $([regex]::Escape($Archive))" -and $Verbosity -eq "Error" + } + } + } + + Context "When the archive expansion fails" { + It "Should return false and log an error" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "bad.zip") } + $badArchive = (Join-Path $TestDrive "bad.zip") + $goodDestination = (Join-Path $TestDrive "extracted") + $result = Expand-DevSetupUpdateArchive -Path $badArchive -DestinationPath $goodDestination + $result | Should -Be $false + Assert-MockCalled Expand-Archive -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $badArchive -and $DestinationPath -eq $goodDestination -and $Force + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expanding archive file from $([regex]::Escape($badArchive)) to $([regex]::Escape($goodDestination))" -and $Verbosity -eq "Debug" + } -Exactly 1 + } + } + + Context "When the archive expansion fails with bad destination" { + It "Should return false and log an error" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "testdest.zip") } + $goodArchive = (Join-Path $TestDrive "testdest.zip") + $badDestination = (Join-Path $TestDrive "badextract") + $result = Expand-DevSetupUpdateArchive -Path $goodArchive -DestinationPath $badDestination + $result | Should -Be $false + Assert-MockCalled Expand-Archive -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $goodArchive -and $DestinationPath -eq $badDestination -and $Force + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expanding archive file from $([regex]::Escape($goodArchive)) to $([regex]::Escape($badDestination))" -and $Verbosity -eq "Debug" + } -Exactly 1 + } + } + + Context "When the archive expansion fails with invalid destination" { + It "Should return false and log an error" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "testdest.zip") } + $goodArchive = (Join-Path $TestDrive "testdest.zip") + $invalidDestination = (Join-Path $TestDrive "invalid\path") + $result = Expand-DevSetupUpdateArchive -Path $goodArchive -DestinationPath $invalidDestination + $result | Should -Be $false + Assert-MockCalled Expand-Archive -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $goodArchive -and $DestinationPath -eq $invalidDestination -and $Force + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expanding archive file from $([regex]::Escape($goodArchive)) to $([regex]::Escape($invalidDestination))" -and $Verbosity -eq "Debug" + } -Exactly 1 + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Failed to expand archive:" -and $Verbosity -eq "Error" + } -Exactly 1 + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Verbosity -eq "Error" + } -Exactly 2 + } + } + + Context "When the archive expansion succeeds" { + It "Should return true and log debug messages" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "test.zip") } + $goodArchive = (Join-Path $TestDrive "test.zip") + $goodDestination = (Join-Path $TestDrive "extracted") + $result = Expand-DevSetupUpdateArchive -Path $goodArchive -DestinationPath $goodDestination + $result | Should -Be $true + Assert-MockCalled Expand-Archive -Exactly 1 -Scope It -ParameterFilter { + $Path -eq $goodArchive -and $DestinationPath -eq $goodDestination -and $Force + } + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expanding archive file from $([regex]::Escape($goodArchive)) to $([regex]::Escape($goodDestination))" -and $Verbosity -eq "Debug" + } -Exactly 1 + Assert-MockCalled Write-StatusMessage -Scope It -ParameterFilter { + $Message -match "Expansion completed successfully." -and $Verbosity -eq "Debug" + } -Exactly 1 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.ps1 b/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.ps1 new file mode 100644 index 0000000..0ea90dc --- /dev/null +++ b/DevSetup/Private/Updater/Expand-DevSetupUpdateArchive.ps1 @@ -0,0 +1,29 @@ +Function Expand-DevSetupUpdateArchive { + [CmdletBinding()] + [OutputType([bool])] + Param( + [Parameter(Mandatory = $true, Position=0)] + [ValidateNotNullOrEmpty()] + [string]$Path, + + [Parameter(Mandatory = $true, Position=1)] + [ValidateNotNullOrEmpty()] + [string]$DestinationPath + ) + + if (-not (Test-Path $Path -ErrorAction SilentlyContinue)) { + Write-StatusMessage "Archive file not found at path: $Path" -Verbosity Error + return $false + } + + try { + Write-StatusMessage "Expanding archive file from $Path to $DestinationPath" -Verbosity Debug + Expand-Archive -Path $Path -DestinationPath $DestinationPath -Force + Write-StatusMessage "Expansion completed successfully." -Verbosity Debug + return $true + } catch { + Write-StatusMessage "Failed to expand archive: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.Tests.ps1 b/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.Tests.ps1 new file mode 100644 index 0000000..c089ffc --- /dev/null +++ b/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.Tests.ps1 @@ -0,0 +1,62 @@ +BeforeAll { + . $PSScriptRoot\Get-DevSetupModuleInstallPath.ps1 + . $PSScriptRoot\..\Providers\Powershell\Get-PowershellModuleScopeMap.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 +} + +Describe "Get-DevSetupModuleInstallPath" { + Context "When DevSetup module is installed in CurrentUser scope" { + It "Should return the correct path" { + $CurrentUserPath = (Join-Path (Join-Path (Join-Path $TestDrive "Documents" ) "PowerShell" ) "Modules") + $AllUsersPath = (Join-Path (Join-Path (Join-Path $TestDrive "ProgramFiles" ) "PowerShell" ) "Modules") + Mock Get-PowershellModuleScopeMap { + return @( + @{ Scope = "CurrentUser"; Path = $CurrentUserPath }, + @{ Scope = "AllUsers"; Path = $AllUsersPath } + ) + } + + $expectedPath = Join-Path -Path $CurrentUserPath -ChildPath "DevSetup" + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $expectedPath } + + $result = Get-DevSetupModuleInstallPath + $result | Should -Be $expectedPath + } + } + + Context "When DevSetup module is installed in AllUsers scope" { + It "Should return the correct path" { + $CurrentUserPath = (Join-Path (Join-Path (Join-Path $TestDrive "Documents" ) "PowerShell" ) "Modules") + $AllUsersPath = (Join-Path (Join-Path (Join-Path $TestDrive "ProgramFiles" ) "PowerShell" ) "Modules") + Mock Get-PowershellModuleScopeMap { + return @( + @{ Scope = "CurrentUser"; Path = $CurrentUserPath }, + @{ Scope = "AllUsers"; Path = $AllUsersPath } + ) + } + + $expectedPath = Join-Path -Path $AllUsersPath -ChildPath "DevSetup" + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $expectedPath } + + $result = Get-DevSetupModuleInstallPath + $result | Should -Be $expectedPath + } + } + + Context "When DevSetup module is not installed" { + It "Should return the first scope path if module is not found" { + $CurrentUserPath = (Join-Path (Join-Path (Join-Path $TestDrive "Documents" ) "PowerShell" ) "Modules") + $AllUsersPath = (Join-Path (Join-Path (Join-Path $TestDrive "ProgramFiles" ) "PowerShell" ) "Modules") + Mock Get-PowershellModuleScopeMap { + return @( + @{ Scope = "CurrentUser"; Path = $CurrentUserPath }, + @{ Scope = "AllUsers"; Path = $AllUsersPath } + ) + } + Mock Test-Path { return $false } + $expectedPath = Join-Path -Path $CurrentUserPath -ChildPath "DevSetup" + $result = Get-DevSetupModuleInstallPath + $result | Should -Be $expectedPath + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.ps1 b/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.ps1 new file mode 100644 index 0000000..1a317ad --- /dev/null +++ b/DevSetup/Private/Updater/Get-DevSetupModuleInstallPath.ps1 @@ -0,0 +1,16 @@ +Function Get-DevSetupModuleInstallPath { + [CmdletBinding()] + Param() + + # Get the module scope map + $ScopeMap = Get-PowershellModuleScopeMap + + foreach ($Scope in $ScopeMap) { + $PotentialPath = Join-Path -Path $Scope.Path -ChildPath "DevSetup" + if (Test-Path -Path $PotentialPath) { + return $PotentialPath + } + } + + return (Join-Path ($ScopeMap | Select-Object -First 1).Path -ChildPath "DevSetup") +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DevSetupUpdateUri.Tests.ps1 b/DevSetup/Private/Updater/Get-DevSetupUpdateUri.Tests.ps1 new file mode 100644 index 0000000..c6f82f4 --- /dev/null +++ b/DevSetup/Private/Updater/Get-DevSetupUpdateUri.Tests.ps1 @@ -0,0 +1,179 @@ +BeforeAll { + Function Invoke-WebRequest { + param ( + [string]$Uri, + [switch]$UseBasicParsing + ) + } + . $PSScriptRoot\Get-DevSetupUpdateUri.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + Mock Invoke-WebRequest { + if ($Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases/latest") { + return @{ Content = @( + @{ + zipball_url = "https://github.com/pwshdevs/devsetup/archive/latest.zip" + tag_name = "latest" + } + ) | ConvertTo-Json } + } elseif ($Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases") { + return @{ Content = @( + @{ + zipball_url = "https://github.com/pwshdevs/devsetup/archive/v1.0.4.zip" + tag_name = "v1.0.4" + } + @{ + zipball_url = "https://github.com/pwshdevs/devsetup/archive/v1.0.3.zip" + tag_name = "v1.0.3" + } + @{ + zipball_url = "https://github.com/pwshdevs/devsetup/archive/v1.0.2.zip" + tag_name = "v1.0.2" + } + ) | ConvertTo-Json } + } else { + throw "Unexpected Uri: $Uri" + } + } +} + +Describe "Get-DevSetupUpdateUri" { + + Context "When Main switch is used" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should not throw" { + { Get-DevSetupUpdateUri -Main } | Should -Not -Throw + } + + It "Should return the main branch URL" { + $result = Get-DevSetupUpdateUri -Main + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/main.zip" + $result.Version | Should -Be "main" + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Main branch selected." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } + + Context "When Develop switch is used" { + BeforeEach { + Mock Write-StatusMessage { } + } + + It "Should not throw" { + { Get-DevSetupUpdateUri -Develop } | Should -Not -Throw + } + + It "Should return the develop branch URL" { + $result = Get-DevSetupUpdateUri -Develop + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/develop.zip" + $result.Version | Should -Be "develop" + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Development branch selected. This may be unstable." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } + Context "When no switch is used and version is latest" { + BeforeEach { + Mock Write-StatusMessage { } + } + + It "Should not throw" { + { Get-DevSetupUpdateUri } | Should -Not -Throw + } + + It "Should return the latest release URL" { + $result = Get-DevSetupUpdateUri + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/v1.0.4.zip" + $result.Version | Should -Be "latest" + Assert-MockCalled Invoke-WebRequest -ParameterFilter { $Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases" -and $UseBasicParsing -eq $true } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Fetching release information from GitHub..." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Fetched 3 releases from GitHub." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Looking for version: latest" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } + Context "When no switch is used and a specific version is given" { + BeforeEach { + Mock Write-StatusMessage { } + } + + It "Should not throw" { + { Get-DevSetupUpdateUri -Version "1.0.3" } | Should -Not -Throw + } + + It "Should return the URI for that version if it exists" { + $result = Get-DevSetupUpdateUri -Version "1.0.3" + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/v1.0.3.zip" + $result.Version | Should -Be "1.0.3" + Assert-MockCalled Invoke-WebRequest -ParameterFilter { $Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases" -and $UseBasicParsing -eq $true } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Fetching release information from GitHub..." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Fetched 3 releases from GitHub." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Looking for version: 1.0.3" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + It "Should call write-statusmessage and return null if version does not exist" { + $result = Get-DevSetupUpdateUri -Version "9.9.9" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -match "No release found matching version: 9.9.9" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Fetching release information from GitHub..." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Fetched 3 releases from GitHub." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Looking for version: 9.9.9" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } + + Context "When multiple switches are used" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should throw an error due to parameter set conflict for Main and Develop" { + { Get-DevSetupUpdateUri -Main -Develop } | Should -Throw + } + It "Should throw an error due to parameter set conflict for Main and Version" { + { Get-DevSetupUpdateUri -Main -Version "1.0.3" } | Should -Throw + } + It "Should throw an error due to parameter set conflict for Develop and Version" { + { Get-DevSetupUpdateUri -Develop -Version "1.0.3" } | Should -Throw + } + } + + Context "When no parameters are provided" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should default to latest version" { + $result = Get-DevSetupUpdateUri -Version $null + $result.Uri | Should -Be "https://github.com/pwshdevs/devsetup/archive/v1.0.4.zip" + $result.Version | Should -Be "latest" + Assert-MockCalled Invoke-WebRequest -ParameterFilter { $Uri -eq "https://api.github.com/repos/pwshdevs/devsetup/releases" -and $UseBasicParsing -eq $true } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Fetching release information from GitHub..." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Fetched 3 releases from GitHub." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Looking for version: latest" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DevSetupUpdateUri.ps1 b/DevSetup/Private/Updater/Get-DevSetupUpdateUri.ps1 new file mode 100644 index 0000000..767a8ad --- /dev/null +++ b/DevSetup/Private/Updater/Get-DevSetupUpdateUri.ps1 @@ -0,0 +1,51 @@ +Function Get-DevSetupUpdateUri { + [CmdletBinding(DefaultParameterSetName="ReleaseInstall")] + [OutputType([hashtable])] + param( + [Parameter(Mandatory=$true, ParameterSetName="MainWebInstall")] + [switch]$Main, + [Parameter(Mandatory=$true, ParameterSetName="DevelopWebInstall")] + [switch]$Develop, + [Parameter(Mandatory=$false, ParameterSetName="ReleaseInstall")] + [string]$Version = "latest" + ) + + $Uri = $null + $VersionToInstall = $null + if($PSBoundParameters.ContainsKey('Main')) { + # Install the main branch + Write-StatusMessage "Main branch selected." -Verbosity Debug + $Uri = "https://github.com/pwshdevs/devsetup/archive/main.zip" + $VersionToInstall = "main" + } elseif($PSBoundParameters.ContainsKey('Develop')) { + # Install the develop branch + Write-StatusMessage "Development branch selected. This may be unstable." -Verbosity Debug + $Uri = "https://github.com/pwshdevs/devsetup/archive/develop.zip" + $VersionToInstall = "develop" + } else { + if( [string]::IsNullOrEmpty($Version)) { + $Version = "latest" + } + Write-StatusMessage "Fetching release information from GitHub..." -Verbosity Debug + # Download the the most current release and install that + $Releases = (Invoke-WebRequest -Uri https://api.github.com/repos/pwshdevs/devsetup/releases -usebasicparsing).Content | convertfrom-json + Write-StatusMessage "Fetched $(($Releases | Measure-Object).Count) releases from GitHub." -Verbosity Debug + Write-StatusMessage "Looking for version: $Version" -Verbosity Debug + if($Version -eq "latest") { + $Uri = $Releases | Select-Object -First 1 | ForEach-Object { $_.zipball_url } + $VersionToInstall = "latest" + } else { + $Uri = $Releases | Foreach-Object { if($_.tag_name -eq "v$Version") { $_.zipball_url } } + if([string]::IsNullOrEmpty($Uri)) { + Write-StatusMessage "No release found matching version: $Version" -Verbosity Error + return $null + } + $VersionToInstall = $Version + } + } + + return @{ + Uri = $Uri + Version = $VersionToInstall + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.Tests.ps1 b/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.Tests.ps1 new file mode 100644 index 0000000..2145e9f --- /dev/null +++ b/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.Tests.ps1 @@ -0,0 +1,95 @@ +BeforeAll { + Function Import-PowerShellDataFile { + Param([string]$Path) + } + $global:LASTEXITCODE = 0 + . $PSScriptRoot\Get-DownloadedDevSetupManifest.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 +} + +Describe "Get-DownloadedDevSetupManifest" { + Context "When Invalid flags are used" { + It "Should throw an error due to missing parameter values" { + { Get-DownloadedDevSetupManifest -ModulePath $null} | Should -Throw + } + It "Should throw an error when ModulePath is blank" { + { Get-DownloadedDevSetupManifest -ModulePath "" } | Should -Throw + } + } + + Context "When a valid ModulePath is provided" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should return null when the ModulePath does not exist" { + Mock Test-Path { $false } -ParameterFilter { $Path -eq (Join-Path $TestDrive "nonexistent") } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "nonexistent") + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Module path not found: $([regex]::Escape((Join-Path $TestDrive "nonexistent")))" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + + } + + It "Should return null when the ModulePath is not a directory" { + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "file.txt") } + Mock Get-Item { return @{ PSIsContainer = $false } } -ParameterFilter { $Path -eq (Join-Path $TestDrive "file.txt") } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "file.txt") + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Module path is not a directory: $([regex]::Escape((Join-Path $TestDrive "file.txt")))" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return null when the DevSetup.psd1 file is missing" { + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "nodir") } + Mock Get-Item { return @{ PSIsContainer = $true } } -ParameterFilter { $Path -eq (Join-Path $TestDrive "nodir") } + Mock Test-Path { $false } -ParameterFilter { $Path -eq (Join-Path (Join-Path $TestDrive "nodir") "DevSetup.psd1") } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "nodir") + $result | Should -Be $null + } + + It "Should return null when the DevSetup.psd1 file does not contain a Version" { + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "noversion") } + Mock Get-Item { return @{ PSIsContainer = $true } } -ParameterFilter { $Path -eq (Join-Path $TestDrive "noversion") } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path (Join-Path $TestDrive "noversion") "DevSetup.psd1") } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "noversion") + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to read version from module manifest at path: $([regex]::Escape((Join-Path (Join-Path $TestDrive "noversion") "DevSetup.psd1")))" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return null when importing the DevSetup.psd1 file throws an error" { + Mock Write-StatusMessage { } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path $TestDrive "error") } + Mock Get-Item { return @{ PSIsContainer = $true } } -ParameterFilter { $Path -eq (Join-Path $TestDrive "error") } + Mock Test-Path { $true } -ParameterFilter { $Path -eq (Join-Path (Join-Path $TestDrive "error") "DevSetup.psd1") } + Mock Import-PowerShellDataFile { throw "Simulated import error" } + $result = Get-DownloadedDevSetupManifest -ModulePath (Join-Path $TestDrive "error") + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Error reading module manifest at path:" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Exactly 2 -Scope It + } + + It "Should return the version string when the DevSetup.psd1 file is valid" { + Mock Write-StatusMessage { + Write-Error $Message + } + $FolderPath = Join-Path $TestDrive "valid" + $PsdPath = Join-Path $FolderPath "DevSetup.psd1" + Mock Test-Path { $true } -ParameterFilter { $Path -eq $FolderPath } + Mock Get-Item { return @{ PSIsContainer = $true } } -ParameterFilter { $Path -eq $FolderPath } + Mock Test-Path { $true } -ParameterFilter { $Path -eq $PsdPath } + Mock Import-PowerShellDataFile { return @{ ModuleVersion = [version]"1.2.3" } } -ParameterFilter { $Path -eq $PsdPath } + $result = Get-DownloadedDevSetupManifest -ModulePath $FolderPath + $result | Should -Not -Be $null + $result | Should -BeOfType [hashtable] + $result.ModuleVersion | Should -BeOfType [version] + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.ps1 b/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.ps1 new file mode 100644 index 0000000..5d79cef --- /dev/null +++ b/DevSetup/Private/Updater/Get-DownloadedDevSetupManifest.ps1 @@ -0,0 +1,37 @@ +Function Get-DownloadedDevSetupManifest { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory=$true)] + [string]$ModulePath + ) + + if(-not (Test-Path $ModulePath)) { + Write-StatusMessage "Module path not found: $ModulePath" -Verbosity Error + return $null + } + + if(-not (Get-Item $ModulePath).PSIsContainer) { + Write-StatusMessage "Module path is not a directory: $ModulePath" -Verbosity Error + return $null + } + + $ModuleManifestPath = Join-Path -Path $ModulePath -ChildPath "DevSetup.psd1" + if(-not (Test-Path $ModuleManifestPath)) { + Write-StatusMessage "Module manifest not found at path: $ModuleManifestPath" -Verbosity Error + return $null + } + + try { + $ModuleManifest = Import-PowerShellDataFile -Path $ModuleManifestPath + if(-not $ModuleManifest -or -not $ModuleManifest.ModuleVersion) { + Write-StatusMessage "Failed to read version from module manifest at path: $ModuleManifestPath" -Verbosity Error + return $null + } + return $ModuleManifest + } catch { + Write-StatusMessage "Error reading module manifest at path: $ModuleManifestPath - $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Install-DevSetupModule.Tests.ps1 b/DevSetup/Private/Updater/Install-DevSetupModule.Tests.ps1 new file mode 100644 index 0000000..4e04b4f --- /dev/null +++ b/DevSetup/Private/Updater/Install-DevSetupModule.Tests.ps1 @@ -0,0 +1,208 @@ +BeforeAll { + Function New-Item { + Param( + [string]$ItemType, + [string]$Path, + [switch]$Force + ) + } + + Function Test-Path { + Param( + [string]$Path + ) + } + Function Copy-Item { + Param( + [string]$Path, + [string]$Destination, + [switch]$Recurse, + [switch]$Force + ) + } + . $PSScriptRoot\Install-DevSetupModule.ps1 + . $PSScriptRoot\Get-DevSetupModuleInstallPath.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + Mock Write-StatusMessage { + #Write-Error $Message + } + $global:InstallPath = (Join-Path (Join-Path (Join-Path (Join-Path $TestDrive "Program Files" ) "WindowsPowerShell" ) "Modules" ) "DevSetup" ) + $global:ModulePath = (Join-Path (Join-Path $TestDrive "Temp" ) "DevSetup" ) +} + +Describe "Install-DevSetupModule" { + Context "When ModulePath is invalid" { + It "Should return false and log an error" { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $ModulePath } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When installation path cannot be determined" { + It "Should return false and log an error" { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock Get-DevSetupModuleInstallPath { return $null } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + + Context "When installation path cannot be determined is null" { + It "Should return false and log an error" { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock Join-Path { return $null } + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + + Context "When installation is successful" { + It "Should return true and log success message" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $InstallPath } + Mock New-Item {} + Mock Copy-Item {} + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Debug' } -Exactly 1 + } + } + + Context "When user declines installation" { + It "Should return false and log a warning message" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $InstallPath } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } -WhatIf:$true -Confirm:$false + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Warning' } -Exactly 1 + } + } + + Context "When installation fails due to an exception" { + It "Should return false and log an error message" { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock New-Item { throw "Simulated failure" } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 2 + } + } + Context "When installation path already exists" { + It "Should skip directory creation and proceed with copying" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } + Mock Copy-Item { return $true } + Mock New-Item { } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $true + Assert-MockCalled New-Item -Exactly 0 + Assert-MockCalled Copy-Item -Exactly 1 + } + } + Context "When Copy-Item fails due to an exception" { + It "Should return false and log an error message" { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock New-Item { return $true } + Mock Copy-Item { throw "Simulated copy failure" } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 2 + } + } + Context "When Manifest does not contain ModuleVersion" { + It "Should return false and log an error message" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{} + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When ModulePath is invalid" { + BeforeEach { + Mock Write-StatusMessage { + Write-Error $Message + } + } + It "Should throw an error when ModulePath is null" { + { Install-DevSetupModule -ModulePath $null -Manifest @{ ModuleVersion = "1.0.0" } } | Should -Throw + } + } + Context "When Manifest is invalid" { + BeforeEach { + Mock Write-StatusMessage { + Write-Error $Message + } + } + It "Should throw error when Manifest is null" { + { Install-DevSetupModule -ModulePath $ModulePath -Manifest $null } | Should -Throw + } + } + Context "When Get-DevSetupModuleInstallPath throws an exception" { + It "Should return false and log an error message" { + Mock Get-DevSetupModuleInstallPath { throw "Simulated path retrieval failure" } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When Manifest version is a complex object" { + It "Should handle non-string version gracefully and return false" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = @{ Major = 1; Minor = 0; Patch = 0 } } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When Manifest version is an empty string" { + It "Should return false and log an error message" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "" } + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Verbosity -eq 'Error' } -Exactly 1 + } + } + Context "When using ShouldProcess functionality" { + It "Should execute normally when ShouldProcess returns true" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock New-Item {} + Mock Copy-Item {} + + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } -WhatIf:$false + $result | Should -Be $true + Should -Invoke New-Item -Times 1 -Exactly + Should -Invoke Copy-Item -Times 1 -Exactly + } + + It "Should skip execution when ShouldProcess returns false" { + Mock Get-DevSetupModuleInstallPath { return $InstallPath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $ModulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $InstallPath } + Mock New-Item { return $true} + Mock Copy-Item { return $true } + $result = Install-DevSetupModule -ModulePath $ModulePath -Manifest @{ ModuleVersion = "1.0.0" } -WhatIf:$true -Confirm:$false + $result | Should -Be $true + Should -Invoke New-Item -Times 0 -Exactly + Should -Invoke Copy-Item -Times 0 -Exactly + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Install-DevSetupModule.ps1 b/DevSetup/Private/Updater/Install-DevSetupModule.ps1 new file mode 100644 index 0000000..0c91210 --- /dev/null +++ b/DevSetup/Private/Updater/Install-DevSetupModule.ps1 @@ -0,0 +1,63 @@ +Function Install-DevSetupModule { + [CmdletBinding(SupportsShouldProcess=$true)] + [OutputType([bool])] + Param( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [String] $ModulePath, + + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [PSObject] $Manifest + ) + + # Determine installation path + if(-not $Manifest.ModuleVersion -or [string]::IsNullOrEmpty($Manifest.ModuleVersion) -or -not ($Manifest.ModuleVersion -is [string])) { + Write-StatusMessage "Invalid or missing version in manifest." -Verbosity Error + return $false + } + + if(-not (Test-Path -Path $ModulePath)) { + Write-StatusMessage "Invalid ModulePath: '$ModulePath'" -Verbosity Error + return $false + } + + try { + $installPath = (Join-Path (Get-DevSetupModuleInstallPath) -ChildPath $Manifest.ModuleVersion) + if ($null -eq $installPath) { + Write-StatusMessage "Failed to determine DevSetup module installation path." -Verbosity Error + return $false + } + } catch { + Write-StatusMessage "Error determining installation path: $_" -Verbosity Error + return $false + } + + if ($PSCmdlet.ShouldProcess("DevSetup Module", "Install to '$installPath'")) { + # Create installation directory if it doesn't exist + if (-not (Test-Path -Path $installPath)) { + try { + New-Item -ItemType Directory -Path $installPath -Force | Out-Null + } catch { + Write-StatusMessage "Failed to create installation directory '$installPath': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + } + + # Copy module files to installation path + try { + Copy-Item -Path (Join-Path -Path $ModulePath -ChildPath '*') -Destination $installPath -Recurse -Force | Out-Null + } catch { + Write-StatusMessage "Failed to copy module files to '$installPath': $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $false + } + + Write-StatusMessage "Successfully installed DevSetup module to '$installPath'." -Verbosity Debug + return $true + } else { + Write-StatusMessage "Installation of DevSetup module to '$installPath' was skipped by user." -Verbosity Warning + return $true + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Install-RequiredDevSetupModules.Tests.ps1 b/DevSetup/Private/Updater/Install-RequiredDevSetupModules.Tests.ps1 new file mode 100644 index 0000000..aa6b6a9 --- /dev/null +++ b/DevSetup/Private/Updater/Install-RequiredDevSetupModules.Tests.ps1 @@ -0,0 +1,124 @@ +BeforeAll { + Function Get-PackageProvider { + Param( + [string]$Name, + [string]$ErrorAction = "SilentlyContinue" + ) + } + + Function Install-PackageProvider { + Param( + [string]$Name, + [switch]$Force, + [switch]$ForceBootstrap + ) + } + + Function Install-Module { + Param( + [string]$Name, + [string]$Scope, + [switch]$Force + ) + } + + Function Get-Module { + Param( + [string]$Name, + [string]$ErrorAction = "SilentlyContinue" + ) + } + + . $PSScriptRoot\Install-RequiredDevSetupModules.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + + Mock Write-StatusMessage { } +} + +Describe "Install-RequiredDevSetupModules" { + Context "When NuGet provider is not installed" { + It "Should attempt to install NuGet provider and return false if installation fails" { + Mock Get-PackageProvider -MockWith { return $null } + Mock Install-PackageProvider { throw "Installation failed" } + $modules = @("ModuleA", "ModuleB") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $false + Assert-MockCalled -CommandName Install-PackageProvider -Times 1 + Assert-MockCalled Write-StatusMessage -Times 1 -ParameterFilter { + $Message -match "Failed to install NuGet PackageProvider:" -and + $Verbosity -eq "Error" + } + } + + It "Should attempt to install NuGet provider and required modules" { + Mock Get-PackageProvider -MockWith { return $null } + Mock Install-PackageProvider + Mock Get-Module -MockWith { return $null } + Mock Install-Module + $modules = @("ModuleA", "ModuleB") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $true + Assert-MockCalled -CommandName Install-PackageProvider -Times 1 + foreach ($module in $modules) { + Assert-MockCalled -CommandName Install-Module -ParameterFilter { $Name -eq $module } -Times 1 + } + } + } + + Context "When NuGet provider is already installed" { + BeforeEach { + Mock Get-PackageProvider -MockWith { return @{ Name = "NuGet" } } + Mock Install-PackageProvider + Mock Get-Module -MockWith { return $null } + Mock Install-Module + } + It "Should skip installing NuGet provider and install required modules" { + $modules = @("ModuleA", "ModuleB") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $true + Assert-MockCalled Install-PackageProvider -Times 0 + foreach ($module in $modules) { + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq $module } -Times 1 + } + } + } + + Context "When a required module is already installed" { + BeforeEach { + Mock Get-PackageProvider -MockWith { return @{ Name = "NuGet" } } + Mock Install-PackageProvider + Mock Get-Module -MockWith { param($Name) if ($Name -eq "ModuleA") { return @{ Name = "ModuleA" } } else { return $null } } + Mock Install-Module + } + It "Should skip installing already installed modules" { + $modules = @("ModuleA", "ModuleB") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $true + Assert-MockCalled Install-PackageProvider -Times 0 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleA" } -Times 0 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleB" } -Times 1 + } + } + Context "When Install-Module fails for a module" { + BeforeEach { + Mock Get-PackageProvider -MockWith { return @{ Name = "NuGet" } } + Mock Install-PackageProvider + Mock Get-Module -MockWith { return $null } + Mock Install-Module -MockWith { param($Name) if ($Name -eq "ModuleB") { throw "Installation failed" } } + Mock Write-StatusMessage { } + } + It "Should log an error and continue installing other modules" { + $modules = @("ModuleA", "ModuleB", "ModuleC") + $result = Install-RequiredDevSetupModules -Modules $modules + $result | Should -Be $true + Assert-MockCalled Install-PackageProvider -Times 0 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleA" } -Times 1 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleB" } -Times 1 + Assert-MockCalled Install-Module -ParameterFilter { $Name -eq "ModuleC" } -Times 1 + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to install module 'ModuleB':" -and + $Verbosity -eq "Error" + } -Times 1 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Install-RequiredDevSetupModules.ps1 b/DevSetup/Private/Updater/Install-RequiredDevSetupModules.ps1 new file mode 100644 index 0000000..c34aad8 --- /dev/null +++ b/DevSetup/Private/Updater/Install-RequiredDevSetupModules.ps1 @@ -0,0 +1,31 @@ +Function Install-RequiredDevSetupModules { + [CmdletBinding()] + Param( + [Parameter(Mandatory=$true)] + $Modules + ) + + # Ensure NuGet provider is available + if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { + try { + Install-PackageProvider -Name NuGet -Force -ForceBootstrap *> $null + } catch { + Write-StatusMessage "Failed to install NuGet PackageProvider: $_" -Verbosity Error + return $false + } + } + + # Install required modules + foreach ($Module in $Modules) { + if (-not (Get-Module -Name $Module -ErrorAction SilentlyContinue)) { + try { + Install-Module -Name $Module -Scope CurrentUser -Force -AllowClobber *> $null + } catch { + Write-StatusMessage "Failed to install module '$Module': $_" -Verbosity Error + } + } else { + Write-StatusMessage "Module '$Module' is already installed. Skipping." -Verbosity Debug + } + } + return $true +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.Tests.ps1 b/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.Tests.ps1 new file mode 100644 index 0000000..ca4d9d9 --- /dev/null +++ b/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.Tests.ps1 @@ -0,0 +1,117 @@ +BeforeAll { + $global:LASTEXITCODE = 0; + Function Invoke-WebRequest { + param ( + [string]$Uri, + [string]$OutFile + ) + if($uri -eq "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.0.zip" -or $uri -eq "http://api.github.com/repos/pwshdevs/devsetup/archive/main.zip" -or + $uri -eq "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.2.zip" -or $uri -eq "http://api.github.com/repos/pwshdevs/devsetup/archive/develop.zip") { + $global:LASTEXITCODE = 0 + # Simulate successful download by creating an empty file + New-Item -Path $OutFile -ItemType File -Force | Out-Null + } elseif($Uri -eq "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.3.zip") { + # Simulate download but file not found after download + $global:LASTEXITCODE = 0 + } elseif($Uri -eq "http://api.github.com/repos/pwshdevs/devsetup/zipball/write-fail.zip") { + $global:LASTEXITCODE = 1 + throw "Unable to save file" + } else { + $global:LASTEXITCODE = 1 + } + } + $global:ArchivePath = Join-Path $TestDrive "devsetup.zip" + . $PSScriptRoot\Invoke-DevSetupDownloadUpdate.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 +} + +Describe "Invoke-DevSetupDownloadUpdate" { + + Context "When Invalid flags are used" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should throw an error due to missing parameter values" { + { Invoke-DevSetupDownloadUpdate -Uri } | Should -Throw + } + It "Should throw an error when both Uri is blank" { + { Invoke-DevSetupDownloadUpdate -Uri "" } | Should -Throw + } + } + + Context "When Invalid Url is provided" { + BeforeEach { + Mock Write-StatusMessage { } + } + It "Should return false and log error for invalid URL" { + $result = Invoke-DevSetupDownloadUpdate -Uri "https://invalid-url.com/file.zip" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Invalid download URL: https://invalid-url.com/file.zip" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + } + + Context "When Valid Url is provided" { + BeforeEach { + Mock Write-StatusMessage { + } + Mock Join-Path { + return $ArchivePath + } + } + AfterEach { + if (Test-Path $ArchivePath) { + Remove-Item $ArchivePath -Force + } + } + It "Should return true and log info for valid URL" { + $result = Invoke-DevSetupDownloadUpdate -Uri "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.0.zip" + $result | Should -Be $ArchivePath + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Downloading update to temporary path: $([regex]::Escape($ArchivePath))" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Starting download from http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.0.zip to $([regex]::Escape($ArchivePath))" -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Download completed successfully." -and $Verbosity -eq "Debug" + } -Exactly 1 -Scope It + } + + It "Should return false and log error if invoke-webrequest throws" { + $result = Invoke-DevSetupDownloadUpdate -Uri "http://api.github.com/repos/pwshdevs/devsetup/zipball/write-fail.zip" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to download update: Unable to save file" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" + } -Exactly 2 -Scope It + } + } + + Context "When Valid Url is provided but file is missing after download" { + BeforeEach { + Mock Write-StatusMessage { + } + Mock Join-Path { + return $ArchivePath + } + Mock Test-Path { $false } -ParameterFilter { $Path -eq $ArchivePath } + } + AfterEach { + if (Test-Path $ArchivePath) { + Remove-Item $ArchivePath -Force + } + } + It "Should return false and log error if file is missing after download" { + $result = Invoke-DevSetupDownloadUpdate -Uri "http://api.github.com/repos/pwshdevs/devsetup/zipball/v1.0.3.zip" + $result | Should -Be $null + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Download completed but file not found at $([regex]::Escape($ArchivePath))" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + Assert-MockCalled Test-Path -ParameterFilter { $Path -eq $ArchivePath } -Exactly 1 -Scope It + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.ps1 b/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.ps1 new file mode 100644 index 0000000..c270e4e --- /dev/null +++ b/DevSetup/Private/Updater/Invoke-DevSetupDownloadUpdate.ps1 @@ -0,0 +1,31 @@ +Function Invoke-DevSetupDownloadUpdate { + [CmdletBinding(DefaultParameterSetName="Download")] + [OutputType([string])] + Param( + [Parameter(Mandatory = $true, ParameterSetName="Download", Position=0)] + [ValidateNotNullOrEmpty()] + [string]$Uri + ) + + if(-not ($Uri -match "api.github.com/repos/pwshdevs/devsetup/zipball") -and -not ($Uri -match "github.com/pwshdevs/devsetup/archive")) { + Write-StatusMessage "Invalid download URL: $Uri" -Verbosity Error + return $null + } + + $DestinationPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "devsetup.zip" + Write-StatusMessage "Downloading update to temporary path: $DestinationPath" -Verbosity Debug + try { + Write-StatusMessage "Starting download from $Uri to $DestinationPath" -Verbosity Debug + Invoke-WebRequest -Uri $Uri -OutFile $DestinationPath + if( -not (Test-Path $DestinationPath -ErrorAction SilentlyContinue)) { + Write-StatusMessage "Download completed but file not found at $DestinationPath" -Verbosity Error + return $null + } + Write-StatusMessage "Download completed successfully." -Verbosity Debug + return $DestinationPath + } catch { + Write-StatusMessage "Failed to download update: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + return $null + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.Tests.ps1 b/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.Tests.ps1 new file mode 100644 index 0000000..242ef9d --- /dev/null +++ b/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.Tests.ps1 @@ -0,0 +1,673 @@ +BeforeAll { + # Use a temporary drive for file operations + # Source the function under test and its direct dependencies + . $PSScriptRoot\Start-DevSetupSelfUpdate.ps1 + . $PSScriptRoot\Get-DevSetupUpdateUri.ps1 + . $PSScriptRoot\Invoke-DevSetupDownloadUpdate.ps1 + . $PSScriptRoot\Expand-DevSetupUpdateArchive.ps1 + . $PSScriptRoot\Get-DownloadedDevSetupManifest.ps1 + . $PSScriptRoot\Install-RequiredDevSetupModules.ps1 + . $PSScriptRoot\Uninstall-DevSetupModule.ps1 + . $PSScriptRoot\Install-DevSetupModule.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\..\Utils\Format-RightText.ps1 + . $PSScriptRoot\..\Utils\Format-LeftText.ps1 + . $PSScriptRoot\..\Utils\Format-CenterText.ps1 + + # Global test variables + $global:TestExtractPath = Join-Path $TestDrive "devsetup_extract" + $global:TestDownloadPath = Join-Path $TestDrive "devsetup.zip" + $global:TestModulePath = Join-Path $TestDrive "DevSetup" +} + +Describe "Start-DevSetupSelfUpdate" { + + Context "Parameter Set Validation" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should accept Main parameter" { + { Start-DevSetupSelfUpdate -Main } | Should -Not -Throw + } + + It "Should accept Develop parameter" { + { Start-DevSetupSelfUpdate -Develop } | Should -Not -Throw + } + + It "Should accept Version parameter" { + { Start-DevSetupSelfUpdate -Version "1.0.0" } | Should -Not -Throw + } + + It "Should accept default parameters (no params)" { + { Start-DevSetupSelfUpdate } | Should -Not -Throw + } + + It "Should not allow Main and Develop together" { + { Start-DevSetupSelfUpdate -Main -Develop } | Should -Throw + } + + It "Should not allow Main and Version together" { + { Start-DevSetupSelfUpdate -Main -Version "1.0.0" } | Should -Throw + } + + It "Should not allow Develop and Version together" { + { Start-DevSetupSelfUpdate -Develop -Version "1.0.0" } | Should -Throw + } + } + + Context "Update URI Validation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should call Get-DevSetupUpdateUri with correct parameters for Main" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/main.zip"; Version = "main" } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Get-DevSetupUpdateUri -Exactly 1 -Scope It -ParameterFilter { $Main -eq $true } + } + + It "Should call Get-DevSetupUpdateUri with correct parameters for Develop" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/develop.zip"; Version = "develop" } } + Start-DevSetupSelfUpdate -Develop + Assert-MockCalled Get-DevSetupUpdateUri -Exactly 1 -Scope It -ParameterFilter { $Develop -eq $true } + } + + It "Should call Get-DevSetupUpdateUri with correct parameters for Version" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/v2.0.0.zip"; Version = "2.0.0" } } + Start-DevSetupSelfUpdate -Version "2.0.0" + Assert-MockCalled Get-DevSetupUpdateUri -Exactly 1 -Scope It -ParameterFilter { $Version -eq "2.0.0" } + } + + It "Should return false when Get-DevSetupUpdateUri fails" { + Mock Get-DevSetupUpdateUri { return $null } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to determine update URI." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display validation status messages" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Validating Installation Type..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Download Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should call Invoke-DevSetupDownloadUpdate with correct URI" { + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Invoke-DevSetupDownloadUpdate -Exactly 1 -Scope It -ParameterFilter { + $Uri -eq "https://test.com/test.zip" + } + } + + It "Should return false when download fails" { + Mock Invoke-DevSetupDownloadUpdate { return $null } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to download update." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display download status messages" { + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Downloading update..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Extraction Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should create temporary extraction path using cross-platform methods" { + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Expand-DevSetupUpdateArchive -Exactly 1 -Scope It + } + + It "Should return false when extraction fails" { + Mock Expand-DevSetupUpdateArchive { return $false } + Mock Test-Path { return $true } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to extract update archive." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should handle extraction exceptions gracefully" { + Mock Expand-DevSetupUpdateArchive { throw "Extraction failed" } + Mock Test-Path { return $true } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to extract update archive: Extraction failed" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return false when extraction path doesn't exist after extraction" { + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $false } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Extraction path not found:" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display extraction status messages" { + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Extracting update..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Module Validation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should validate downloaded module manifest" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Get-DownloadedDevSetupManifest -Exactly 1 -Scope It + } + + It "Should return false when manifest is invalid" { + Mock Get-DownloadedDevSetupManifest { return $null } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to read downloaded module manifest." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return false when manifest has no version" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = $null; RequiredModules = @() } } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Downloaded module manifest does not contain a valid version." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return false when manifest has empty version" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = ""; RequiredModules = @() } } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Downloaded module manifest does not contain a valid version." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display validation status messages" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Validating downloaded module..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Prerequisites Installation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should install required modules from manifest" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @("TestModule1", "TestModule2") } } + Mock Install-RequiredDevSetupModules { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Install-RequiredDevSetupModules -Exactly 1 -Scope It -ParameterFilter { + $Modules -contains "TestModule1" -and $Modules -contains "TestModule2" + } + } + + It "Should continue on prerequisites installation failure" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @("TestModule1") } } + Mock Install-RequiredDevSetupModules { throw "Installation failed" } + Start-DevSetupSelfUpdate -Main + # Should continue to uninstall step + Assert-MockCalled Uninstall-DevSetupModule -Exactly 1 -Scope It + } + + It "Should display prerequisites status messages" { + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Installing required prerequisites..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Module Uninstallation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should uninstall old DevSetup module" { + Mock Uninstall-DevSetupModule { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Uninstall-DevSetupModule -Exactly 1 -Scope It + } + + It "Should return false when uninstallation fails" { + Mock Uninstall-DevSetupModule { throw "Uninstall failed" } + $result = Start-DevSetupSelfUpdate -Main + ($result | Select-Object -Last 1) | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to uninstall old DevSetup module: Uninstall failed" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display uninstallation status messages" { + Mock Uninstall-DevSetupModule { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Uninstalling old DevSetup module..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Module Installation Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should install new DevSetup module" { + Mock Install-DevSetupModule { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Install-DevSetupModule -Exactly 1 -Scope It + } + + It "Should return false when installation returns false" { + Mock Install-DevSetupModule { return $false } + $result = Start-DevSetupSelfUpdate -Main + ($result | Select-Object -Last 1) | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "Failed to install new DevSetup module." -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should return false when installation throws exception" { + Mock Install-DevSetupModule { throw "Install failed" } + $result = Start-DevSetupSelfUpdate -Main + ($result | Select-Object -Last 1) | Should -Be $false + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -match "Failed to install new DevSetup module: Install failed" -and $Verbosity -eq "Error" + } -Exactly 1 -Scope It + } + + It "Should display installation status messages" { + Mock Install-DevSetupModule { return $true } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Installing new DevSetup module..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Installation Verification Phase" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + } + + It "Should verify module installation using Get-Module" { + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Get-Module -Exactly 1 -Scope It -ParameterFilter { + $ListAvailable -eq $true -and $Name -eq "DevSetup" + } + } + + It "Should display verification results when module is found" { + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Verifying installation..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + + It "Should display failure when module is not found" { + Mock Get-Module { return $null } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "- Verifying installation..." -and $ForegroundColor -eq "Gray" + } -Exactly 1 -Scope It + } + } + + Context "Success Path Integration" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should complete successfully with all phases working" { + $result = Start-DevSetupSelfUpdate -Main + # Should not return false (successful completion doesn't return anything) + $result | Should -Not -Be $false + } + + It "Should display completion messages on success" { + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq "`nInstallation completed successfully!" -and $ForegroundColor -eq "Green" + } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Message -eq " Please restart your PowerShell session to use the updated module." -and $ForegroundColor -eq "White" + } -Exactly 1 -Scope It + } + + It "Should work with Version parameter" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/v1.5.0.zip"; Version = "1.5.0" } } + $result = Start-DevSetupSelfUpdate -Version "1.5.0" + $result | Should -Not -Be $false + } + + It "Should work with default parameters" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/latest.zip"; Version = "latest" } } + $result = Start-DevSetupSelfUpdate + $result | Should -Not -Be $false + } + } + + Context "Cross-Platform Path Handling" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should use Join-Path for cross-platform compatibility" { + # Create test directory structure in TestDrive + $TestExtractDir = Join-Path $TestDrive "extracted" + New-Item -Path $TestExtractDir -ItemType Directory -Force + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractDir }) } + + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Not -Be $false + } + + It "Should handle Windows path separators" { + if ($IsWindows -or $PSVersionTable.PSVersion.Major -le 5) { + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = Join-Path $TestDrive "test-extract" }) } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Not -Be $false + } + } + + It "Should handle Unix path separators" { + if ($IsLinux -or $IsMacOS) { + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = Join-Path $TestDrive "test-extract" }) } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Not -Be $false + } else { + # On Windows/PS5.1, just verify it doesn't throw + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = Join-Path $TestDrive "test-extract" }) } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Not -Be $false + } + } + } + + Context "PowerShell 5.1 Compatibility" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should not use PowerShell 6+ only features" { + # Verify no use of ?? operator or other PS6+ features + $functionContent = Get-Content $PSScriptRoot\Start-DevSetupSelfUpdate.ps1 -Raw + $functionContent | Should -Not -Match '\?\?' # Null coalescing operator + $functionContent | Should -Not -Match '\?\.' # Null conditional operator + } + + It "Should use compatible string operations" { + # Test that string operations work in PS 5.1 + { Start-DevSetupSelfUpdate -Version "test" } | Should -Not -Throw + } + + It "Should use compatible array operations" { + # Test that array/hashtable operations work in PS 5.1 + Mock Get-DownloadedDevSetupManifest { + return @{ + ModuleVersion = "1.0.0" + RequiredModules = @("Module1", "Module2") + } + } + { Start-DevSetupSelfUpdate -Main } | Should -Not -Throw + } + + It "Should work with older .NET Framework methods" { + # Test Path operations that work in .NET Framework 4.x + { Start-DevSetupSelfUpdate -Main } | Should -Not -Throw + } + } + + Context "Error Handling and Edge Cases" { + BeforeEach { + Mock Write-StatusMessage { } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should handle null return from Get-DevSetupUpdateUri" { + Mock Get-DevSetupUpdateUri { return $null } + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + } + + It "Should handle empty extraction directory" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Get-ChildItem { return @() } + try { + $result = Start-DevSetupSelfUpdate -Main + # Function should handle this gracefully + $result | Should -BeIn @($null, $false) + } catch { + # It's okay if this throws an error, as the function is handling an invalid state + $_.Exception.Message | Should -Match "Cannot bind argument to parameter" + } + } + + It "Should handle manifest reading failures" { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Get-DownloadedDevSetupManifest { throw "Cannot read manifest" } + try { + $result = Start-DevSetupSelfUpdate -Main + $result | Should -Be $false + } catch { + # It's okay if this throws, as we're testing error handling + $_.Exception.Message | Should -Match "Cannot read manifest" + } + } + + It "Should display appropriate error messages for each failure point" { + Mock Get-DevSetupUpdateUri { return $null } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { + $Verbosity -eq "Error" -and $Message -eq "Failed to determine update URI." + } -Exactly 1 -Scope It + } + } + + Context "Status Message Display" { + BeforeEach { + Mock Get-DevSetupUpdateUri { return @{ Uri = "https://test.com/test.zip"; Version = "test" } } + Mock Invoke-DevSetupDownloadUpdate { return $TestDownloadPath } + Mock Expand-DevSetupUpdateArchive { return $true } + Mock Test-Path { return $true } + Mock Get-ChildItem { return @([PSCustomObject]@{ FullName = $TestExtractPath }) } + Mock Get-DownloadedDevSetupManifest { return @{ ModuleVersion = "1.0.0"; RequiredModules = @() } } + Mock Install-RequiredDevSetupModules { return $true } + Mock Uninstall-DevSetupModule { return $true } + Mock Install-DevSetupModule { return $true } + Mock Get-Module { return @{ Version = "1.0.0"; Name = "DevSetup" } } + } + + It "Should show progress indicators throughout the process" { + Mock Write-StatusMessage { } + Start-DevSetupSelfUpdate -Main + + # Verify all major status messages are displayed + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Validating Installation Type..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Downloading update..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Extracting update..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Validating downloaded module..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Installing required prerequisites..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Uninstalling old DevSetup module..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Installing new DevSetup module..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Verifying installation..." } -Exactly 1 -Scope It + } + + It "Should show version information" { + Mock Write-StatusMessage { } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Installing DevSetup Version..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Checking PowerShell Version..." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "- Checking PowerShell Edition..." } -Exactly 1 -Scope It + } + + It "Should show completion messages" { + Mock Write-StatusMessage { } + Start-DevSetupSelfUpdate -Main + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "`nInstallation completed successfully!" } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "You can now use DevSetup commands in any PowerShell session." } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq "`nTo get started:" } -Exactly 1 -Scope It + Assert-MockCalled Write-StatusMessage -ParameterFilter { $Message -eq " Please restart your PowerShell session to use the updated module." } -Exactly 1 -Scope It + } + } +} diff --git a/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.ps1 b/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.ps1 new file mode 100644 index 0000000..787ea8b --- /dev/null +++ b/DevSetup/Private/Updater/Start-DevSetupSelfUpdate.ps1 @@ -0,0 +1,140 @@ +Function Start-DevSetupSelfUpdate { + [CmdletBinding(DefaultParameterSetName="ReleaseInstall")] + param( + [Parameter(Mandatory=$true, ParameterSetName="MainWebInstall")] + [switch]$Main, + [Parameter(Mandatory=$true, ParameterSetName="DevelopWebInstall")] + [switch]$Develop, + [Parameter(Mandatory=$false, ParameterSetName="ReleaseInstall")] + [string]$Version = "latest" + ) + + $successCheck = [char]0x2714 # ✔️ + $failureCheck = [char]0x2613 # + + # ------ Validate installation type and get update URI ------ + Write-StatusMessage "- Validating Installation Type..." -Width 60 -ForegroundColor Gray -NoNewLine + $UpdateChoice = Get-DevSetupUpdateUri @PSBoundParameters + if(-not $UpdateChoice) { + Write-StatusMessage "Failed to determine update URI." -Verbosity Error + return $false + } + Write-StatusMessage (Format-RightText "[$($UpdateChoice.Version)]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + # ------ Download update ------ + Write-StatusMessage "- Downloading update..." -Width 60 -ForegroundColor Gray -NoNewLine + $UpdateArchive = Invoke-DevSetupDownloadUpdate -Uri $UpdateChoice.Uri + if(-not $UpdateArchive) { + Write-StatusMessage "Failed to download update." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + + # ------ Extract update ------ + Write-StatusMessage "- Extracting update..." -Width 60 -ForegroundColor Gray -NoNewLine + $ExtractPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("devsetup_update_" + [System.Guid]::NewGuid().ToString()) + Write-StatusMessage "Extracting update archive to temporary path: $ExtractPath" -Verbosity Debug + try { + if( -not (Expand-DevSetupUpdateArchive -Path $UpdateArchive -DestinationPath $ExtractPath)) { + Write-StatusMessage "Failed to extract update archive." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + } catch { + Write-StatusMessage "Failed to extract update archive: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + + if( -not $ExtractPath -or -not (Test-Path $ExtractPath -ErrorAction SilentlyContinue)) { + Write-StatusMessage "Extraction path not found: $ExtractPath" -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + # ------ Validate downloaded module ------ + Write-StatusMessage "- Validating downloaded module..." -Width 60 -ForegroundColor Gray -NoNewLine + $ExtractedModulePath = (Get-ChildItem -Path $ExtractPath | Select-Object -First 1).FullName + $DownloadedModulePath = Join-Path -Path $ExtractedModulePath -ChildPath "DevSetup" + + $DownloadedManifest = Get-DownloadedDevSetupManifest -ModulePath $DownloadedModulePath + if(-not $DownloadedManifest) { + Write-StatusMessage "Failed to read downloaded module manifest." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + if(-not $DownloadedManifest.ModuleVersion -or [string]::IsNullOrEmpty($DownloadedManifest.ModuleVersion)) { + Write-StatusMessage "Downloaded module manifest does not contain a valid version." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + Write-StatusMessage "- Installing DevSetup Version..." -Width 60 -NoNewLine -ForegroundColor Gray + Write-StatusMessage (Format-RightText "[$($DownloadedManifest.ModuleVersion)]" 20) -ForegroundColor Green + + Write-StatusMessage "- Checking PowerShell Version..." -Width 60 -NoNewLine -ForegroundColor Gray + Write-StatusMessage (Format-RightText "[$($PSVersionTable.PSVersion)]" 20) -ForegroundColor Green + Write-StatusMessage "- Checking PowerShell Edition..." -Width 60 -NoNewLine -ForegroundColor Gray + Write-StatusMessage (Format-RightText "[$($PSVersionTable.PSEdition)]" 20) -ForegroundColor Green + + # --------- Install prerequisites ------------------------- + Write-StatusMessage "- Installing required prerequisites..." -Width 60 -NoNewLine -ForegroundColor Gray + try { + Install-RequiredDevSetupModules -Modules $DownloadedManifest.RequiredModules + } catch { + Write-StatusMessage "Failed to install required modules: $_" -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + # --------- Uninstall old module version ------------------------- + Write-StatusMessage "- Uninstalling old DevSetup module..." -Width 60 -NoNewLine -ForegroundColor Gray + try { + Uninstall-DevSetupModule + } catch { + Write-StatusMessage "Failed to uninstall old DevSetup module: $_" -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + # --------- Install new module version ------------------------- + Write-StatusMessage "- Installing new DevSetup module..." -Width 60 -NoNewLine -ForegroundColor Gray + try { + if(-not (Install-DevSetupModule -ModulePath $DownloadedModulePath -Manifest $DownloadedManifest)) { + Write-StatusMessage "Failed to install new DevSetup module." -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + } catch { + Write-StatusMessage "Failed to install new DevSetup module: $_" -Verbosity Error + Write-StatusMessage $_.ScriptStackTrace -Verbosity Error + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + return $false + } + Write-StatusMessage (Format-RightText "[$successCheck]" 20) -ForegroundColor Green + # ------------------------------------------------------ + + $ModuleFound = Get-Module -ListAvailable -Name "DevSetup" -ErrorAction SilentlyContinue + Write-StatusMessage "- Verifying installation..." -Width 60 -NoNewLine -ForegroundColor Gray + if ($ModuleFound) { + Write-StatusMessage (Format-RightText "[$($ModuleFound.Version)]" 20) -ForegroundColor Green + } else { + Write-StatusMessage (Format-RightText "[$failureCheck]" 20) -ForegroundColor Red + } + Write-StatusMessage "`nInstallation completed successfully!" -ForegroundColor Green + Write-StatusMessage "You can now use DevSetup commands in any PowerShell session." -ForegroundColor White + Write-StatusMessage "`nTo get started:" -ForegroundColor Cyan + Write-StatusMessage " Please restart your PowerShell session to use the updated module." -ForegroundColor White +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Uninstall-DevSetupModule.Tests.ps1 b/DevSetup/Private/Updater/Uninstall-DevSetupModule.Tests.ps1 new file mode 100644 index 0000000..6b4cb0b --- /dev/null +++ b/DevSetup/Private/Updater/Uninstall-DevSetupModule.Tests.ps1 @@ -0,0 +1,72 @@ +BeforeAll { + Function Remove-Item { + Param( + [string]$Path, + [switch]$Recurse, + [switch]$Force + ) + } + + Function Test-Path { + Param( + [string]$Path + ) + } + + . $PSScriptRoot\Uninstall-DevSetupModule.ps1 + . $PSScriptRoot\..\Utils\Write-StatusMessage.ps1 + . $PSScriptRoot\Get-DevSetupModuleInstallPath.ps1 + Mock Write-StatusMessage { } +} + +Describe "Uninstall-DevSetupModule" { + Context "When DevSetup module is installed" { + It "Should uninstall the module and return true" { + $modulePath = Join-Path -Path $TestDrive -ChildPath "DevSetup" + + Mock Get-DevSetupModuleInstallPath { return $modulePath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $modulePath } + Mock Remove-Item { return $true } + + $result = Uninstall-DevSetupModule + $result | Should -Be $true + + Assert-MockCalled Remove-Item -Times 1 -ParameterFilter { $Path -eq $modulePath } + Assert-MockCalled Write-StatusMessage -Times 1 -ParameterFilter { + $Message -match "Successfully uninstalled DevSetup module from '$([regex]::Escape($modulePath))'." + } + } + + It "Should handle errors during uninstallation and return false" { + $modulePath = Join-Path -Path $TestDrive -ChildPath "DevSetup" + + Mock Get-DevSetupModuleInstallPath { return $modulePath } + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $modulePath } + Mock Remove-Item { throw "Error during removal" } + + $result = Uninstall-DevSetupModule + $result | Should -Be $false + + Assert-MockCalled -CommandName Remove-Item -Times 1 -ParameterFilter { $Path -eq $modulePath } + Assert-MockCalled Write-StatusMessage -Times 1 -ParameterFilter { + $Message -match "Failed to uninstall DevSetup module from '$([regex]::Escape($modulePath))': Error during removal" -and + $Verbosity -eq "Error" + } + } + } + + Context "When DevSetup module is not installed" { + It "Should return true and indicate no action taken" { + $modulePath = Join-Path -Path $TestDrive -ChildPath "DevSetup" + Mock Get-DevSetupModuleInstallPath { return $modulePath } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $modulePath } + + $result = Uninstall-DevSetupModule + $result | Should -Be $true + Assert-MockCalled Write-StatusMessage -Times 1 -ParameterFilter { + $Message -eq "DevSetup module is not installed. No action taken." -and + $Verbosity -eq "Warning" + } + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Updater/Uninstall-DevSetupModule.ps1 b/DevSetup/Private/Updater/Uninstall-DevSetupModule.ps1 new file mode 100644 index 0000000..3965e83 --- /dev/null +++ b/DevSetup/Private/Updater/Uninstall-DevSetupModule.ps1 @@ -0,0 +1,20 @@ +Function Uninstall-DevSetupModule { + [CmdletBinding()] + [OutputType([bool])] + Param() + + $modulePath = Get-DevSetupModuleInstallPath + if ($null -ne $modulePath -and (Test-Path -Path $modulePath)) { + try { + Remove-Item -Recurse -Force -Path $modulePath | Out-Null + Write-StatusMessage "Successfully uninstalled DevSetup module from '$modulePath'." -Verbosity Debug + return $true + } catch { + Write-StatusMessage "Failed to uninstall DevSetup module from '$modulePath': $_" -Verbosity Error + return $false + } + } else { + Write-StatusMessage "DevSetup module is not installed. No action taken." -Verbosity Warning + return $true + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Assert-DevSetupEnvValid.Tests.ps1 b/DevSetup/Private/Utils/Assert-DevSetupEnvValid.Tests.ps1 index c131614..9130201 100644 --- a/DevSetup/Private/Utils/Assert-DevSetupEnvValid.Tests.ps1 +++ b/DevSetup/Private/Utils/Assert-DevSetupEnvValid.Tests.ps1 @@ -2038,5 +2038,44 @@ Describe "Assert-DevSetupEnvValid" { # For now, we document this as a known edge case $true | Should -Be $true # Placeholder test to document the edge case } + } + + Context "Assert-PackageManagerValid - PowerShell Manager Coverage" { + It "Should throw when PowerShell manager has no packages, modules, or buckets" { + # This targets line 302: PowerShell manager with valid scope but no arrays + # Should throw "Manager 'powershell' must contain at least one of: 'packages', 'modules', or 'buckets'." + + $powershellManagerWithNoArrays = @{ + scope = "CurrentUser" # Valid scope + # Missing packages, modules, and buckets arrays + } + + { Assert-PackageManagerValid -ManagerName "powershell" -ManagerData $powershellManagerWithNoArrays } | + Should -Throw "*Manager 'powershell' must contain at least one of: 'packages', 'modules', or 'buckets'*" + } + + It "Should throw when Scoop manager has no packages, modules, or buckets" { + # This targets line 320: Scoop manager with no arrays + # Should throw "Manager 'scoop' must contain at least one of: 'packages', 'modules', or 'buckets'." + + $scoopManagerWithNoArrays = @{ + # Missing packages, modules, and buckets arrays + } + + { Assert-PackageManagerValid -ManagerName "scoop" -ManagerData $scoopManagerWithNoArrays } | + Should -Throw "*Manager 'scoop' must contain at least one of: 'packages', 'modules', or 'buckets'*" + } + + It "Should throw when Homebrew manager has no packages, modules, or buckets" { + # This targets line 338: Homebrew manager with no arrays + # Should throw "Manager 'homebrew' must contain at least one of: 'packages', 'modules', or 'buckets'." + + $homebrewManagerWithNoArrays = @{ + # Missing packages, modules, and buckets arrays + } + + { Assert-PackageManagerValid -ManagerName "homebrew" -ManagerData $homebrewManagerWithNoArrays } | + Should -Throw "*Manager 'homebrew' must contain at least one of: 'packages', 'modules', or 'buckets'*" + } } } diff --git a/DevSetup/Private/Utils/Assert-DevSetupEnvValid.ps1 b/DevSetup/Private/Utils/Assert-DevSetupEnvValid.ps1 index e02cb7c..b8916ff 100644 --- a/DevSetup/Private/Utils/Assert-DevSetupEnvValid.ps1 +++ b/DevSetup/Private/Utils/Assert-DevSetupEnvValid.ps1 @@ -230,8 +230,6 @@ function Assert-CommandsValid { $params.Keys } elseif ($params -is [PSCustomObject]) { $params.PSObject.Properties.Name - } else { - @() } foreach ($key in $paramKeys) { @@ -549,8 +547,6 @@ function Assert-DependenciesValid { $Dependencies.Keys } elseif ($Dependencies -is [PSCustomObject]) { $Dependencies.PSObject.Properties.Name - } else { - @() } foreach ($manager in $managerNames) { diff --git a/DevSetup/Private/Utils/Format-CenterText.Tests.ps1 b/DevSetup/Private/Utils/Format-CenterText.Tests.ps1 new file mode 100644 index 0000000..48928a6 --- /dev/null +++ b/DevSetup/Private/Utils/Format-CenterText.Tests.ps1 @@ -0,0 +1,284 @@ +BeforeAll { + . $PSScriptRoot\Format-CenterText.ps1 +} + +Describe "Format-CenterText" { + + Context "When centering text within specified width" { + It "Should center text with equal padding on both sides for even padding" { + $result = Format-CenterText -Text "Hello" -Width 11 + $result | Should -Be " Hello " + $result.Length | Should -Be 11 + } + + It "Should center text with left padding one less than right for odd padding" { + $result = Format-CenterText -Text "Hello" -Width 12 + $result | Should -Be " Hello " + $result.Length | Should -Be 12 + } + + It "Should center single character text" { + $result = Format-CenterText -Text "X" -Width 5 + $result | Should -Be " X " + $result.Length | Should -Be 5 + } + + It "Should center text with minimum width" { + $result = Format-CenterText -Text " " -Width 6 + $result | Should -Be " " + $result.Length | Should -Be 6 + } + + It "Should center text with width of 1" { + $result = Format-CenterText -Text "A" -Width 1 + $result | Should -Be "A" + $result.Length | Should -Be 1 + } + } + + Context "When text width equals specified width" { + It "Should return text unchanged when lengths are equal" { + $text = "Hello" + $result = Format-CenterText -Text $text -Width 5 + $result | Should -Be $text + $result.Length | Should -Be 5 + } + + It "Should return long text unchanged when lengths are equal" { + $text = "This is a test" + $result = Format-CenterText -Text $text -Width 14 + $result | Should -Be $text + $result.Length | Should -Be 14 + } + } + + Context "When text width exceeds specified width" { + It "Should return text unchanged when text is longer than width" { + $text = "This text is too long" + $result = Format-CenterText -Text $text -Width 10 + $result | Should -Be $text + $result.Length | Should -Be 21 + } + + It "Should return text unchanged when width is 0" { + $text = "Hello" + $result = Format-CenterText -Text $text -Width 0 + $result | Should -Be $text + } + + It "Should return text unchanged when width is negative" { + $text = "Hello" + $result = Format-CenterText -Text $text -Width -5 + $result | Should -Be $text + } + } + + Context "When handling special characters and unicode" { + It "Should center text with spaces" { + $result = Format-CenterText -Text "Hello World" -Width 21 + $result | Should -Be " Hello World " + $result.Length | Should -Be 21 + } + + It "Should center text with tabs" { + $text = "`tTab`t" + $result = Format-CenterText -Text $text -Width 10 + $result.Length | Should -Be 10 + $result | Should -Match "Tab" + } + + It "Should center text with newlines" { + $text = "Line1`nLine2" + $result = Format-CenterText -Text $text -Width 20 + $result.Length | Should -Be 20 + $result | Should -Match "Line1" + $result | Should -Match "Line2" + } + + It "Should handle unicode characters" { + $result = Format-CenterText -Text "Héllo" -Width 11 + $result | Should -Be " Héllo " + $result.Length | Should -Be 11 + } + + It "Should handle special symbols" { + $result = Format-CenterText -Text "A*B" -Width 9 + $result | Should -Be " A*B " + $result.Length | Should -Be 9 + } + } + + Context "When handling different data types" { + It "Should convert numbers to strings and center them" { + $result = Format-CenterText -Text 123 -Width 7 + $result | Should -Be " 123 " + $result.Length | Should -Be 7 + } + + It "Should convert boolean to strings and center them" { + $result = Format-CenterText -Text $true -Width 8 + $result | Should -Be " True " + $result.Length | Should -Be 8 + } + + It "Should handle string values that are effectively empty" { + $result = Format-CenterText -Text " " -Width 6 + $result | Should -Be " " + $result.Length | Should -Be 6 + } + + It "Should handle objects by converting to string representation" { + $obj = [PSCustomObject]@{ Name = "Test" } + $result = Format-CenterText -Text $obj -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match "Name=Test" + } + } + + Context "When testing edge cases and boundary conditions" { + It "Should handle very large width values" { + $result = Format-CenterText -Text "Hi" -Width 1000 + $result.Length | Should -Be 1000 + $result | Should -Match "^\s{499}Hi\s{499}$" + } + + It "Should handle width of exactly text length plus 1" { + $result = Format-CenterText -Text "Test" -Width 5 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should handle width of exactly text length plus 2" { + $result = Format-CenterText -Text "Test" -Width 6 + $result | Should -Be " Test " + $result.Length | Should -Be 6 + } + } + + Context "When testing mathematical calculations" { + It "Should correctly calculate left padding for odd total padding" { + # Text = "ABC" (3 chars), Width = 10, Padding = 7, Left = 3, Right = 4 + $result = Format-CenterText -Text "ABC" -Width 10 + $leftSpaces = ($result -split 'ABC')[0].Length + $rightSpaces = ($result -split 'ABC')[1].Length + $leftSpaces | Should -Be 3 + $rightSpaces | Should -Be 4 + $result | Should -Be " ABC " + } + + It "Should correctly calculate left padding for even total padding" { + # Text = "AB" (2 chars), Width = 8, Padding = 6, Left = 3, Right = 3 + $result = Format-CenterText -Text "AB" -Width 8 + $leftSpaces = ($result -split 'AB')[0].Length + $rightSpaces = ($result -split 'AB')[1].Length + $leftSpaces | Should -Be 3 + $rightSpaces | Should -Be 3 + $result | Should -Be " AB " + } + + It "Should use Math.Floor for left padding calculation" { + # Verify that left padding uses floor (truncates decimals) + # Text = "X" (1 char), Width = 4, Padding = 3, Left = Floor(1.5) = 1, Right = 2 + $result = Format-CenterText -Text "X" -Width 4 + $leftSpaces = ($result -split 'X')[0].Length + $rightSpaces = ($result -split 'X')[1].Length + $leftSpaces | Should -Be 1 + $rightSpaces | Should -Be 2 + $result | Should -Be " X " + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Format-CenterText -Text "Windows" -Width 15 + $result | Should -Be " Windows " + $result.Length | Should -Be 15 + } + + It "Should work on Linux" { + $result = Format-CenterText -Text "Linux" -Width 13 + $result | Should -Be " Linux " + $result.Length | Should -Be 13 + } + + It "Should work on macOS" { + $result = Format-CenterText -Text "macOS" -Width 11 + $result | Should -Be " macOS " + $result.Length | Should -Be 11 + } + } + + Context "PowerShell 5.1 compatibility" { + It "Should not use PowerShell 6+ only features" { + # Verify no use of ?? operator or other PS6+ features + $functionContent = Get-Content $PSScriptRoot\Format-CenterText.ps1 -Raw + $functionContent | Should -Not -Match '\?\?' # Null coalescing operator + $functionContent | Should -Not -Match '\?\.' # Null conditional operator + } + + It "Should use compatible Math.Floor method" { + # Test that Math.Floor works in PS 5.1 + { Format-CenterText -Text "Test" -Width 10 } | Should -Not -Throw + } + + It "Should use compatible string operations" { + # Test string multiplication and concatenation work in PS 5.1 + $result = Format-CenterText -Text "PS5.1" -Width 15 + $result | Should -Be " PS5.1 " + $result.Length | Should -Be 15 + } + + It "Should work with older .NET Framework string handling" { + # Test that string operations work with .NET Framework 4.x + $result = Format-CenterText -Text "Framework" -Width 17 + $result | Should -Be " Framework " + $result.Length | Should -Be 17 + } + } + + Context "Performance and stress testing" { + It "Should handle multiple consecutive calls efficiently" { + $results = @() + for ($i = 1; $i -le 100; $i++) { + $results += Format-CenterText -Text "Item$i" -Width 20 + } + $results.Count | Should -Be 100 + $results[0] | Should -Match "Item1" + $results[99] | Should -Match "Item100" + } + + It "Should handle very long text efficiently" { + $longText = "A" * 1000 + $result = Format-CenterText -Text $longText -Width 500 + $result | Should -Be $longText # Should return unchanged since text > width + $result.Length | Should -Be 1000 + } + + It "Should handle repeated characters" { + $result = Format-CenterText -Text ("X" * 5) -Width 15 + $result | Should -Be " XXXXX " + $result.Length | Should -Be 15 + } + } + + Context "Parameter validation and error handling" { + It "Should accept mandatory Text parameter" { + { Format-CenterText -Text "Required" -Width 10 } | Should -Not -Throw + } + + It "Should accept mandatory Width parameter" { + { Format-CenterText -Text "Test" -Width 5 } | Should -Not -Throw + } + + It "Should handle zero width gracefully" { + $result = Format-CenterText -Text "Test" -Width 0 + $result | Should -Be "Test" + } + + It "Should handle extremely large width values" { + # Test with large but reasonable width (avoid memory issues in test environment) + $result = Format-CenterText -Text "Big" -Width 100 + $result.Length | Should -Be 100 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-CenterText.ps1 b/DevSetup/Private/Utils/Format-CenterText.ps1 new file mode 100644 index 0000000..e84e865 --- /dev/null +++ b/DevSetup/Private/Utils/Format-CenterText.ps1 @@ -0,0 +1,17 @@ +Function Format-CenterText { + param( + [Parameter(Mandatory=$true)] + [string]$Text, + [Parameter(Mandatory=$true)] + [int]$Width + ) + + $Text = "$Text" + $Pad = $Width - $Text.Length + if ($Pad -le 0) { + return $Text + } + $Left = [math]::Floor($Pad / 2) + $Right = $Pad - $Left + return (' ' * $Left) + $Text + (' ' * $Right) +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-LeftText.Tests.ps1 b/DevSetup/Private/Utils/Format-LeftText.Tests.ps1 new file mode 100644 index 0000000..0fc9c31 --- /dev/null +++ b/DevSetup/Private/Utils/Format-LeftText.Tests.ps1 @@ -0,0 +1,368 @@ +BeforeAll { + . $PSScriptRoot\Format-LeftText.ps1 +} + +Describe "Format-LeftText" { + + Context "When left-aligning text within specified width" { + It "Should left-align text with leading space and trailing spaces" { + $result = Format-LeftText -Text "Hello" -Width 10 + $result | Should -Be " Hello " + $result.Length | Should -Be 10 + } + + It "Should left-align single character text" { + $result = Format-LeftText -Text "X" -Width 5 + $result | Should -Be " X " + $result.Length | Should -Be 5 + } + + It "Should left-align text with minimum width" { + $result = Format-LeftText -Text "Hi" -Width 4 + $result | Should -Be " Hi " + $result.Length | Should -Be 4 + } + + It "Should handle width of exactly text length plus 1" { + $result = Format-LeftText -Text "Test" -Width 5 + $result | Should -Be " Test" + $result.Length | Should -Be 5 + } + + It "Should handle width of exactly text length plus 2" { + $result = Format-LeftText -Text "Test" -Width 6 + $result | Should -Be " Test " + $result.Length | Should -Be 6 + } + } + + Context "When text width equals or exceeds specified width" { + It "Should return text with leading space when formatted text equals width" { + $result = Format-LeftText -Text "Test" -Width 5 + $result | Should -Be " Test" + $result.Length | Should -Be 5 + } + + It "Should return text unchanged when formatted text exceeds width" { + $result = Format-LeftText -Text "This is a long text" -Width 10 + $result | Should -Be " This is a long text" + $result.Length | Should -Be 20 # Original length + 1 for leading space + } + + It "Should return text unchanged when width is 0" { + $result = Format-LeftText -Text "Hello" -Width 0 + $result | Should -Be " Hello" + $result.Length | Should -Be 6 + } + + It "Should return text unchanged when width is negative" { + $result = Format-LeftText -Text "Hello" -Width -5 + $result | Should -Be " Hello" + $result.Length | Should -Be 6 + } + + It "Should handle very long text exceeding width" { + $longText = "A" * 50 + $result = Format-LeftText -Text $longText -Width 20 + $result | Should -Be " $longText" + $result.Length | Should -Be 51 + } + } + + Context "When handling special characters and content" { + It "Should left-align text with spaces" { + $result = Format-LeftText -Text "Hello World" -Width 20 + $result | Should -Be " Hello World " + $result.Length | Should -Be 20 + } + + It "Should handle text with tabs" { + $result = Format-LeftText -Text "Tab`tText" -Width 15 + $result | Should -Be " Tab`tText " + $result.Length | Should -Be 15 + } + + It "Should handle text with newlines" { + $result = Format-LeftText -Text "Line1`nLine2" -Width 20 + $result | Should -Be " Line1`nLine2 " + $result.Length | Should -Be 20 + } + + It "Should handle unicode characters" { + $result = Format-LeftText -Text "Héllo" -Width 12 + $result | Should -Be " Héllo " + $result.Length | Should -Be 12 + } + + It "Should handle text with leading spaces" { + $result = Format-LeftText -Text " Spaced" -Width 12 + $result | Should -Be " Spaced " + $result.Length | Should -Be 12 + } + + It "Should handle text with trailing spaces" { + $result = Format-LeftText -Text "Spaced " -Width 12 + $result | Should -Be " Spaced " + $result.Length | Should -Be 12 + } + } + + Context "When handling different data types" { + It "Should convert numbers to strings and left-align them" { + $result = Format-LeftText -Text 123 -Width 8 + $result | Should -Be " 123 " + $result.Length | Should -Be 8 + } + + It "Should convert boolean to strings and left-align them" { + $result = Format-LeftText -Text $true -Width 10 + $result | Should -Be " True " + $result.Length | Should -Be 10 + } + + It "Should convert zero to string and left-align it" { + $result = Format-LeftText -Text 0 -Width 6 + $result | Should -Be " 0 " + $result.Length | Should -Be 6 + } + + It "Should convert false to string and left-align it" { + $result = Format-LeftText -Text $false -Width 8 + $result | Should -Be " False " + $result.Length | Should -Be 8 + } + + It "Should handle objects by converting to string representation" { + $obj = [PSCustomObject]@{ Name = "Test" } + $result = Format-LeftText -Text $obj -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match " @{Name=Test}" + } + } + + Context "When testing edge cases and boundary conditions" { + It "Should handle very large width values" { + $result = Format-LeftText -Text "Small" -Width 100 + $result.Length | Should -Be 100 + $result | Should -Match "^ Small" + $result | Should -Match " {94}$" # 94 trailing spaces + } + + It "Should handle width of 1 with single character" { + $result = Format-LeftText -Text "A" -Width 1 + $result | Should -Be " A" + $result.Length | Should -Be 2 + } + + It "Should handle width of 2 with single character" { + $result = Format-LeftText -Text "A" -Width 2 + $result | Should -Be " A" + $result.Length | Should -Be 2 + } + + It "Should handle width of 3 with single character" { + $result = Format-LeftText -Text "A" -Width 3 + $result | Should -Be " A " + $result.Length | Should -Be 3 + } + } + + Context "When testing string manipulation behavior" { + It "Should always add exactly one leading space" { + $testCases = @( + @{ Text = "A"; Width = 10 } + @{ Text = "Hello"; Width = 10 } + @{ Text = "Very Long Text"; Width = 5 } + ) + + foreach ($case in $testCases) { + $result = Format-LeftText -Text $case.Text -Width $case.Width + $result | Should -Match "^ " # Should always start with exactly one space + $result.Substring(0, 1) | Should -Be " " + } + } + + It "Should preserve original text after leading space" { + $originalText = "Preserve This Text" + $result = Format-LeftText -Text $originalText -Width 30 + $result.Substring(1, $originalText.Length) | Should -Be $originalText + } + + It "Should pad with spaces when width is larger than formatted text" { + $result = Format-LeftText -Text "Pad" -Width 10 + $trailingPart = $result.Substring(4) # After " Pad" + $trailingPart | Should -Be " " # 6 spaces + $trailingPart.Length | Should -Be 6 + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Format-LeftText -Text "Windows" -Width 15 + $result | Should -Be " Windows " + $result.Length | Should -Be 15 + } + + It "Should work on Linux" { + $result = Format-LeftText -Text "Linux" -Width 12 + $result | Should -Be " Linux " + $result.Length | Should -Be 12 + } + + It "Should work on macOS" { + $result = Format-LeftText -Text "macOS" -Width 10 + $result | Should -Be " macOS " + $result.Length | Should -Be 10 + } + } + + Context "PowerShell 5.1 compatibility" { + It "Should not use PowerShell 6+ only features" { + # Verify no use of ?? operator or other PS6+ features + $functionContent = Get-Content $PSScriptRoot\Format-LeftText.ps1 -Raw + $functionContent | Should -Not -Match '\?\?' # Null coalescing operator + $functionContent | Should -Not -Match '\?\.' # Null conditional operator + } + + It "Should use compatible string operations" { + # Test string concatenation and multiplication work in PS 5.1 + $result = Format-LeftText -Text "PS5.1" -Width 15 + $result | Should -Be " PS5.1 " + $result.Length | Should -Be 15 + } + + It "Should work with older .NET Framework string handling" { + # Test that string operations work with .NET Framework 4.x + $result = Format-LeftText -Text "Framework" -Width 18 + $result | Should -Be " Framework " + $result.Length | Should -Be 18 + } + + It "Should handle string length calculations correctly" { + # Test .Length property works correctly in PS 5.1 + $text = "Test" + $result = Format-LeftText -Text $text -Width 10 + $result.Length | Should -Be 10 + ($result.Substring(1, $text.Length)) | Should -Be $text + } + } + + Context "Performance and stress testing" { + It "Should handle multiple consecutive calls efficiently" { + $results = @() + for ($i = 1; $i -le 50; $i++) { + $results += Format-LeftText -Text "Item$i" -Width 15 + } + $results.Count | Should -Be 50 + $results[0] | Should -Be " Item1 " + $results[49] | Should -Be " Item50 " + } + + It "Should handle very long text efficiently" { + $longText = "B" * 500 + $result = Format-LeftText -Text $longText -Width 100 + $result | Should -Be " $longText" + $result.Length | Should -Be 501 + } + + It "Should handle repeated characters in padding" { + $result = Format-LeftText -Text "X" -Width 20 + $result | Should -Be " X " + $result.Length | Should -Be 20 + # Verify it's actually spaces in the padding + $padding = $result.Substring(2) + $padding | Should -Match "^ {18}$" + } + + It "Should handle wide characters efficiently" { + $result = Format-LeftText -Text "Wide" -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match "^ Wide {45}$" + } + } + + Context "Mathematical calculations and logic" { + It "Should calculate padding correctly for various widths" { + $testCases = @( + @{ Text = "Hi"; Width = 5; ExpectedPadding = 2 } # " Hi" (3) needs 2 more + @{ Text = "Test"; Width = 8; ExpectedPadding = 3 } # " Test" (5) needs 3 more + @{ Text = "A"; Width = 10; ExpectedPadding = 8 } # " A" (2) needs 8 more + ) + + foreach ($case in $testCases) { + $result = Format-LeftText -Text $case.Text -Width $case.Width + $paddingLength = $result.Length - (" " + $case.Text).Length + $paddingLength | Should -Be $case.ExpectedPadding + } + } + + It "Should handle boundary condition where formatted text equals width" { + $result = Format-LeftText -Text "Exact" -Width 6 + $result | Should -Be " Exact" + $result.Length | Should -Be 6 + # No additional padding should be added + } + + It "Should handle the greater-than-or-equal condition correctly" { + # Test the boundary where $Text.Length == $Width + $result = Format-LeftText -Text "12345" -Width 6 # " 12345" = 6 chars + $result | Should -Be " 12345" + $result.Length | Should -Be 6 + + # Test where $Text.Length > $Width + $result2 = Format-LeftText -Text "123456" -Width 6 # " 123456" = 7 chars > 6 + $result2 | Should -Be " 123456" + $result2.Length | Should -Be 7 + } + } + + Context "Parameter validation behavior" { + It "Should accept mandatory Text parameter" { + { Format-LeftText -Text "Required" -Width 10 } | Should -Not -Throw + } + + It "Should accept mandatory Width parameter" { + { Format-LeftText -Text "Test" -Width 5 } | Should -Not -Throw + } + + It "Should handle zero width gracefully" { + $result = Format-LeftText -Text "Test" -Width 0 + $result | Should -Be " Test" + $result.Length | Should -Be 5 + } + + It "Should handle negative width gracefully" { + $result = Format-LeftText -Text "Test" -Width -10 + $result | Should -Be " Test" + $result.Length | Should -Be 5 + } + + It "Should handle extremely large width values without error" { + # Test with large but reasonable width + $result = Format-LeftText -Text "Big" -Width 200 + $result.Length | Should -Be 200 + $result.Substring(0, 4) | Should -Be " Big" + } + } + + Context "String formatting consistency" { + It "Should maintain consistent formatting pattern" { + $testTexts = @("A", "AB", "ABC", "ABCD", "ABCDE") + $width = 10 + + foreach ($text in $testTexts) { + $result = Format-LeftText -Text $text -Width $width + $result.Length | Should -Be $width + $result | Should -Match "^ $text" + $result.Substring(0, 1) | Should -Be " " + $result.Substring(1, $text.Length) | Should -Be $text + } + } + + It "Should handle whitespace-only input" { + $result = Format-LeftText -Text " " -Width 10 + $result | Should -Be (" " + (" " * 6)) # " " + " " + 6 padding spaces = 10 total spaces + $result.Length | Should -Be 10 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-LeftText.ps1 b/DevSetup/Private/Utils/Format-LeftText.ps1 new file mode 100644 index 0000000..2150607 --- /dev/null +++ b/DevSetup/Private/Utils/Format-LeftText.ps1 @@ -0,0 +1,14 @@ +Function Format-LeftText { + param( + [Parameter(Mandatory=$true)] + [string]$Text, + [Parameter(Mandatory=$true)] + [int]$Width + ) + + $Text = " $Text" + if ($Text.Length -ge $Width) { + return $Text + } + return $Text + (' ' * ($Width - $Text.Length)) +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-PrettyTable.Tests.ps1 b/DevSetup/Private/Utils/Format-PrettyTable.Tests.ps1 index 48c3d29..3cc62bf 100644 --- a/DevSetup/Private/Utils/Format-PrettyTable.Tests.ps1 +++ b/DevSetup/Private/Utils/Format-PrettyTable.Tests.ps1 @@ -148,6 +148,110 @@ Describe "Format-PrettyTable" { } } + Context "When using NoHeader table format" { + It "Should skip header output when NoHeader is true" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray"; NoHeader = $true } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 5 -Scope It # Top, row, bottom (no header/middle) + } + } + + Context "When handling null/empty text values" { + It "Should replace null/empty text with [BLANK] in center alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = ""; Width = 10; Alignment = "Center"; Color = "White" } + } + $rows = @( + @{ Name = $null } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should replace null/empty text with [BLANK] in left alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = " "; Width = 10; Alignment = "Left"; Color = "White" } + } + $rows = @( + @{ Name = "" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should replace null/empty text with [BLANK] in right alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = $null; Width = 10; Alignment = "Right"; Color = "White" } + } + $rows = @( + @{ Name = " " } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "When text width equals or exceeds column width" { + It "Should handle text width greater than column width in center alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = "VeryLongHeaderText"; Width = 5; Alignment = "Center"; Color = "White" } + } + $rows = @( + @{ Name = "Short" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should handle text width equal to column width in right alignment" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Right"; Color = "White" } + } + $rows = @( + @{ Name = "TenCharsTxt" } # Exactly 10 chars + 1 space prefix = 11 chars >= 10 width + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + + Context "When using default alignment" { + It "Should handle default alignment for column headers" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "Unknown"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + + It "Should handle default alignment for row values" { + $columns = @{ + Name = @{ Key = "Name"; Name = "Name"; Width = 10; Alignment = "InvalidType"; Color = "White" } + } + $rows = @( + @{ Name = "Alice" } + ) + $tableFormat = @{ BorderColor = "Gray" } + Format-PrettyTable -Columns $columns -Rows $rows -TableFormat $tableFormat + Assert-MockCalled Write-StatusMessage -Exactly 9 -Scope It + } + } + Context "Cross-platform compatibility" { It "Should work on Windows" { $columns = @{ diff --git a/DevSetup/Private/Utils/Format-RightText.Tests.ps1 b/DevSetup/Private/Utils/Format-RightText.Tests.ps1 new file mode 100644 index 0000000..02a49d0 --- /dev/null +++ b/DevSetup/Private/Utils/Format-RightText.Tests.ps1 @@ -0,0 +1,371 @@ +BeforeAll { + . $PSScriptRoot\Format-RightText.ps1 +} + +Describe "Format-RightText" { + + Context "When right-aligning text within specified width" { + It "Should right-align text with trailing space and leading spaces" { + $result = Format-RightText -Text "Hello" -Width 10 + $result | Should -Be " Hello " + $result.Length | Should -Be 10 + } + + It "Should right-align single character text" { + $result = Format-RightText -Text "X" -Width 5 + $result | Should -Be " X " + $result.Length | Should -Be 5 + } + + It "Should right-align text with minimum width" { + $result = Format-RightText -Text "Hi" -Width 4 + $result | Should -Be " Hi " + $result.Length | Should -Be 4 + } + + It "Should handle width of exactly text length plus 1" { + $result = Format-RightText -Text "Test" -Width 5 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should handle width of exactly text length plus 2" { + $result = Format-RightText -Text "Test" -Width 6 + $result | Should -Be " Test " + $result.Length | Should -Be 6 + } + } + + Context "When text width equals or exceeds specified width" { + It "Should return text with trailing space when formatted text equals width" { + $result = Format-RightText -Text "Test" -Width 5 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should return text unchanged when formatted text exceeds width" { + $result = Format-RightText -Text "This is a long text" -Width 10 + $result | Should -Be "This is a long text " + $result.Length | Should -Be 20 # Original length + 1 for trailing space + } + + It "Should return text unchanged when width is 0" { + $result = Format-RightText -Text "Hello" -Width 0 + $result | Should -Be "Hello " + $result.Length | Should -Be 6 + } + + It "Should return text unchanged when width is negative" { + $result = Format-RightText -Text "Hello" -Width -5 + $result | Should -Be "Hello " + $result.Length | Should -Be 6 + } + + It "Should handle very long text exceeding width" { + $longText = "A" * 50 + $result = Format-RightText -Text $longText -Width 20 + $result | Should -Be "$longText " + $result.Length | Should -Be 51 + } + } + + Context "When handling special characters and content" { + It "Should right-align text with spaces" { + $result = Format-RightText -Text "Hello World" -Width 20 + $result | Should -Be " Hello World " + $result.Length | Should -Be 20 + } + + It "Should handle text with tabs" { + $result = Format-RightText -Text "Tab`tText" -Width 15 + $result | Should -Be " Tab`tText " + $result.Length | Should -Be 15 + } + + It "Should handle text with newlines" { + $result = Format-RightText -Text "Line1`nLine2" -Width 20 + $result | Should -Be " Line1`nLine2 " + $result.Length | Should -Be 20 + } + + It "Should handle unicode characters" { + $result = Format-RightText -Text "Héllo" -Width 12 + $result | Should -Be " Héllo " + $result.Length | Should -Be 12 + } + + It "Should handle text with leading spaces" { + $result = Format-RightText -Text " Spaced" -Width 12 + $result | Should -Be " Spaced " + $result.Length | Should -Be 12 + } + + It "Should handle text with trailing spaces" { + $result = Format-RightText -Text "Spaced " -Width 12 + $result | Should -Be " Spaced " + $result.Length | Should -Be 12 + } + } + + Context "When handling different data types" { + It "Should convert numbers to strings and right-align them" { + $result = Format-RightText -Text 123 -Width 8 + $result | Should -Be " 123 " + $result.Length | Should -Be 8 + } + + It "Should convert boolean to strings and right-align them" { + $result = Format-RightText -Text $true -Width 10 + $result | Should -Be " True " + $result.Length | Should -Be 10 + } + + It "Should convert zero to string and right-align it" { + $result = Format-RightText -Text 0 -Width 6 + $result | Should -Be " 0 " + $result.Length | Should -Be 6 + } + + It "Should convert false to string and right-align it" { + $result = Format-RightText -Text $false -Width 8 + $result | Should -Be " False " + $result.Length | Should -Be 8 + } + + It "Should handle objects by converting to string representation" { + $obj = [PSCustomObject]@{ Name = "Test" } + $result = Format-RightText -Text $obj -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match "@{Name=Test} " + } + } + + Context "When testing edge cases and boundary conditions" { + It "Should handle very large width values" { + $result = Format-RightText -Text "Small" -Width 100 + $result.Length | Should -Be 100 + $result | Should -Match "Small $" + $result | Should -Match "^ {94}Small $" # 94 leading spaces + } + + It "Should handle width of 1 with single character" { + $result = Format-RightText -Text "A" -Width 1 + $result | Should -Be "A " + $result.Length | Should -Be 2 + } + + It "Should handle width of 2 with single character" { + $result = Format-RightText -Text "A" -Width 2 + $result | Should -Be "A " + $result.Length | Should -Be 2 + } + + It "Should handle width of 3 with single character" { + $result = Format-RightText -Text "A" -Width 3 + $result | Should -Be " A " + $result.Length | Should -Be 3 + } + } + + Context "When testing string manipulation behavior" { + It "Should always add exactly one trailing space" { + $testCases = @( + @{ Text = "A"; Width = 5 } + @{ Text = "AB"; Width = 5 } + @{ Text = "ABC"; Width = 5 } + @{ Text = "ABCD"; Width = 5 } + @{ Text = "ABCDE"; Width = 5 } + ) + + foreach ($case in $testCases) { + $result = Format-RightText -Text $case.Text -Width $case.Width + $result | Should -Match "$($case.Text) $" + $result.Substring($result.Length - 1) | Should -Be " " + $result.Substring($result.Length - 2, 1) | Should -Be $case.Text.Substring($case.Text.Length - 1) + } + } + + It "Should preserve original text before trailing space" { + $originalText = "Preserve This Text" + $result = Format-RightText -Text $originalText -Width 30 + $result.Substring($result.Length - ($originalText.Length + 1), $originalText.Length) | Should -Be $originalText + } + + It "Should pad with spaces when width is larger than formatted text" { + $result = Format-RightText -Text "Pad" -Width 10 + $leadingPart = $result.Substring(0, 6) # Before "Pad " + $leadingPart | Should -Be " " # 6 spaces + $leadingPart.Length | Should -Be 6 + } + } + + Context "Cross-platform compatibility" { + It "Should work on Windows" { + $result = Format-RightText -Text "Windows" -Width 15 + $result | Should -Be " Windows " + $result.Length | Should -Be 15 + } + + It "Should work on Linux" { + $result = Format-RightText -Text "Linux" -Width 12 + $result | Should -Be " Linux " + $result.Length | Should -Be 12 + } + + It "Should work on macOS" { + $result = Format-RightText -Text "macOS" -Width 10 + $result | Should -Be " macOS " + $result.Length | Should -Be 10 + } + } + + Context "PowerShell 5.1 compatibility" { + It "Should not use PowerShell 6+ only features" { + # Verify no use of ?? operator or other PS6+ features + $functionContent = Get-Content $PSScriptRoot\Format-RightText.ps1 -Raw + $functionContent | Should -Not -Match '\?\?' # Null coalescing operator + $functionContent | Should -Not -Match '\?\.' # Null conditional operator + } + + It "Should use compatible string operations" { + # Test string concatenation and multiplication work in PS 5.1 + $result = Format-RightText -Text "PS5.1" -Width 15 + $result | Should -Be " PS5.1 " + $result.Length | Should -Be 15 + } + + It "Should work with older .NET Framework string handling" { + # Test that string operations work with .NET Framework 4.x + $result = Format-RightText -Text "Framework" -Width 18 + $result | Should -Be " Framework " + $result.Length | Should -Be 18 + } + + It "Should handle string length calculations correctly" { + # Test .Length property works correctly in PS 5.1 + $text = "Test" + $result = Format-RightText -Text $text -Width 10 + $result.Length | Should -Be 10 + $result.Substring($result.Length - ($text.Length + 1), $text.Length) | Should -Be $text + } + } + + Context "Performance and stress testing" { + It "Should handle multiple consecutive calls efficiently" { + $results = @() + for ($i = 1; $i -le 50; $i++) { + $results += Format-RightText -Text "Item$i" -Width 15 + } + $results.Count | Should -Be 50 + $results[0] | Should -Be " Item1 " + $results[49] | Should -Be " Item50 " + } + + It "Should handle very long text efficiently" { + $longText = "B" * 500 + $result = Format-RightText -Text $longText -Width 100 + $result | Should -Be "$longText " + $result.Length | Should -Be 501 + } + + It "Should handle repeated characters in padding" { + $result = Format-RightText -Text "X" -Width 20 + $result | Should -Be " X " + $result.Length | Should -Be 20 + # Verify it's actually spaces in the padding + $padding = $result.Substring(0, 18) + $padding | Should -Match "^ {18}$" + } + + It "Should handle wide characters efficiently" { + $result = Format-RightText -Text "Wide" -Width 50 + $result.Length | Should -Be 50 + $result | Should -Match "^ {45}Wide $" + } + } + + Context "Mathematical calculations and logic" { + It "Should calculate padding correctly for various widths" { + $testCases = @( + @{ Text = "Hi"; Width = 5; ExpectedPadding = 2 } # "Hi " (3) needs 2 more + @{ Text = "Test"; Width = 8; ExpectedPadding = 3 } # "Test " (5) needs 3 more + @{ Text = "A"; Width = 10; ExpectedPadding = 8 } # "A " (2) needs 8 more + ) + + foreach ($case in $testCases) { + $result = Format-RightText -Text $case.Text -Width $case.Width + $paddingLength = $result.Length - ("$($case.Text) ").Length + $paddingLength | Should -Be $case.ExpectedPadding + } + } + + It "Should handle boundary condition where formatted text equals width" { + $result = Format-RightText -Text "Exact" -Width 6 + $result | Should -Be "Exact " + $result.Length | Should -Be 6 + # No additional padding should be added + } + + It "Should handle the greater-than-or-equal condition correctly" { + # Test the boundary where $Text.Length == $Width + $result = Format-RightText -Text "12345" -Width 6 # "12345 " = 6 chars + $result | Should -Be "12345 " + $result.Length | Should -Be 6 + + # Test where $Text.Length > $Width + $result2 = Format-RightText -Text "123456" -Width 6 # "123456 " = 7 chars > 6 + $result2 | Should -Be "123456 " + $result2.Length | Should -Be 7 + } + } + + Context "Parameter validation behavior" { + It "Should accept mandatory Text parameter" { + { Format-RightText -Text "Required" -Width 10 } | Should -Not -Throw + } + + It "Should accept mandatory Width parameter" { + { Format-RightText -Text "Test" -Width 5 } | Should -Not -Throw + } + + It "Should handle zero width gracefully" { + $result = Format-RightText -Text "Test" -Width 0 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should handle negative width gracefully" { + $result = Format-RightText -Text "Test" -Width -10 + $result | Should -Be "Test " + $result.Length | Should -Be 5 + } + + It "Should handle extremely large width values without error" { + # Test with large but reasonable width + $result = Format-RightText -Text "Big" -Width 200 + $result.Length | Should -Be 200 + $result | Should -Match "Big $" + } + } + + Context "String formatting consistency" { + It "Should maintain consistent formatting pattern" { + $testTexts = @("A", "AB", "ABC", "ABCD", "ABCDE") + $width = 10 + + foreach ($text in $testTexts) { + $result = Format-RightText -Text $text -Width $width + $result.Length | Should -Be $width + $result | Should -Match "$text $" + $result.Substring($result.Length - 1) | Should -Be " " + $result.Substring($result.Length - ($text.Length + 1), $text.Length) | Should -Be $text + } + } + + It "Should handle whitespace-only input" { + $result = Format-RightText -Text " " -Width 10 + $result | Should -Be " " # 7 leading spaces + " " + trailing space = 10 total + $result.Length | Should -Be 10 + } + } +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Format-RightText.ps1 b/DevSetup/Private/Utils/Format-RightText.ps1 new file mode 100644 index 0000000..a7d64ce --- /dev/null +++ b/DevSetup/Private/Utils/Format-RightText.ps1 @@ -0,0 +1,12 @@ +Function Format-RightText { + param( + [Parameter(Mandatory=$true)] + [string]$Text, + [Parameter(Mandatory=$true)] + [int]$Width + ) + + $Text = "$Text " + if ($Text.Length -ge $Width) { return $Text } + return (' ' * ($Width - $Text.Length)) + $Text +} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Start-DevSetupSelfUpdate.ps1 b/DevSetup/Private/Utils/Start-DevSetupSelfUpdate.ps1 deleted file mode 100644 index dfff6e6..0000000 --- a/DevSetup/Private/Utils/Start-DevSetupSelfUpdate.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -Function Start-DevSetupSelfUpdate { - [CmdletBinding()] - Param() - - $manifest = Get-DevSetupManifest - if($null -eq $manifest) { - throw "Failed to load manifest file" - } - - $communityEnvironmentsProjectUri = $manifest.PrivateData.PSData.EnvironmentsProjectUri - $devsetupProjectUri = $manifest.PrivateData.PSData.ProjectUri - - $currentVersion = Get-DevSetupVersion - - $devsetupCurrentReleaseInfo = Get-GitHubRelease -Uri $devsetupProjectUri | Select-Object -First 1 - $communityEnvironmentsCurrentReleaseInfo = Get-GitHubRelease -Uri $communityEnvironmentsProjectUri | Select-Object -First 1 - - $devsetupCurrentReleaseVersion = [Version]::new(($devsetupCurrentReleaseInfo.name -Replace "v")) - if($currentVersion -lt $devsetupCurrentReleaseVersion) { - - } -} \ No newline at end of file diff --git a/DevSetup/Private/Utils/Write-NewConfig.Tests.ps1 b/DevSetup/Private/Utils/Write-NewConfig.Tests.ps1 index e89d40f..0f9f7d5 100644 --- a/DevSetup/Private/Utils/Write-NewConfig.Tests.ps1 +++ b/DevSetup/Private/Utils/Write-NewConfig.Tests.ps1 @@ -237,6 +237,35 @@ Describe "Write-NewConfig" { Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It Assert-MockCalled Write-StatusMessage -Exactly 1 -Scope It -ParameterFilter { $Message -match "- Version" } } + } + + Context "When updating existing configuration with lastModified field" { + It "should preserve existing lastModified timestamp" { + Mock Test-RunningAsAdmin { $true } + Mock Get-HostArchitecture { "x64" } + Mock Get-HostOperatingSystem { "Windows" } + Mock Get-HostOperatingSystemVersion { "10.0.19042" } + Mock Get-EnvironmentVariable { "TestUser" } + Mock Get-Date { [DateTime]::Parse("2023-01-01 12:00:00") } + Mock Test-Path { $true } + Mock Read-DevSetupEnvFile { @{ devsetup = @{ configuration = @{ version = "1.0.0"; description = "Existing config"; createdDate = "2022-01-01 12:00:00"; createdBy = "OldUser"; lastModified = "2022-06-15 14:30:00" }; dependencies = @{ chocolatey = @{ packages = @("git") } }; commands = @("echo hello") } } } + Mock Update-DevSetupEnvFile { } + Mock Write-StatusMessage { } + Mock Test-OperatingSystem { $true } # Windows + Mock Invoke-ChocolateyPackageExport { $true } + Mock Invoke-ScoopComponentExport { $true } + Mock Invoke-PowershellModulesExport { $true } + Mock ConvertFrom-3rdPartyInstall { } + Mock Optimize-DevSetupEnvs { } + + $result = Write-NewConfig -OutFile "test.yaml" + $result | Should -Be $true + Assert-MockCalled Read-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Update-DevSetupEnvFile -Exactly 1 -Scope It + Assert-MockCalled Invoke-ChocolateyPackageExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-ScoopComponentExport -Exactly 1 -Scope It + Assert-MockCalled Invoke-PowershellModulesExport -Exactly 1 -Scope It + } } Context "When reading existing config fails" { diff --git a/DevSetup/Public/Use-DevSetup.Tests.ps1 b/DevSetup/Public/Use-DevSetup.Tests.ps1 index 59dfc30..4ec76e0 100644 --- a/DevSetup/Public/Use-DevSetup.Tests.ps1 +++ b/DevSetup/Public/Use-DevSetup.Tests.ps1 @@ -10,6 +10,7 @@ BeforeAll { . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Export-DevSetupEnv.ps1") . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Show-DevSetupEnvList.ps1") . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Uninstall-DevSetupEnv.ps1") + . (Join-Path $PSScriptRoot "..\..\DevSetup\Private\Commands\Show-ExplainDevSetupEnv.ps1") Mock Write-Host { } Mock Write-StatusMessage { } Mock Write-Error { } @@ -63,21 +64,6 @@ Describe "Use-DevSetup" { } } - Context "When updating to latest" { - It "should call Update-DevSetup with Latest parameter" { - Mock Get-DevSetupVersion { "1.0.9" } - Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } - Mock Write-StatusMessage { } - Mock Write-Host { } - Mock Write-EZLog { } - Mock Update-DevSetup { } - - $result = Use-DevSetup -Update - $result | Should -Be $null # Update doesn't return a value - Assert-MockCalled Update-DevSetup -Exactly 1 -Scope It -ParameterFilter { $Latest -eq $true } - } - } - Context "When updating to main" { It "should call Update-DevSetup with Main parameter" { Mock Get-DevSetupVersion { "1.0.9" } @@ -123,6 +109,21 @@ Describe "Use-DevSetup" { } } + Context "When updating without specific branch or version" { + It "should call Update-DevSetup with Version set to 'latest'" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Update-DevSetup { } + + $result = Use-DevSetup -Update + $result | Should -Be $null + Assert-MockCalled Update-DevSetup -Exactly 1 -Scope It -ParameterFilter { $Version -eq "latest" } + } + } + Context "When initializing" { It "should call Initialize-DevSetup" { Mock Get-DevSetupVersion { "1.0.9" } @@ -212,6 +213,21 @@ Describe "Use-DevSetup" { } } + Context "When listing by provider and platform" { + It "should call Show-DevSetupEnvList with Provider and Platform parameters" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Show-DevSetupEnvList { } + + $result = Use-DevSetup -List -Provider "Chocolatey" -Platform "Windows" + $result | Should -Be $null + Assert-MockCalled Show-DevSetupEnvList -Exactly 1 -Scope It -ParameterFilter { $Provider -eq "Chocolatey" -and $Platform -eq "Windows" } + } + } + Context "When uninstalling" { It "should call Uninstall-DevSetupEnv with Name parameter" { Mock Get-DevSetupVersion { "1.0.9" } @@ -227,6 +243,36 @@ Describe "Use-DevSetup" { } } + Context "When explaining with name" { + It "should call Show-ExplainDevSetupEnv with Name parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Show-ExplainDevSetupEnv { $true } + + $result = Use-DevSetup -Explain -Name "TestEnv" + $result | Should -Be $true + Assert-MockCalled Show-ExplainDevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Name -eq "TestEnv" } + } + } + + Context "When explaining from path" { + It "should call Show-ExplainDevSetupEnv with Path parameter" { + Mock Get-DevSetupVersion { "1.0.9" } + Mock Get-DevSetupLogPath { Join-Path $TestDrive "logs" } + Mock Write-StatusMessage { } + Mock Write-Host { } + Mock Write-EZLog { } + Mock Show-ExplainDevSetupEnv { $true } + + $result = Use-DevSetup -Explain -Path "C:\Configs\test.yaml" + $result | Should -Be $true + Assert-MockCalled Show-ExplainDevSetupEnv -Exactly 1 -Scope It -ParameterFilter { $Path -eq "C:\Configs\test.yaml" } + } + } + Context "When an error occurs" { It "should handle exceptions and log errors" { Mock Get-DevSetupVersion { "1.0.9" } diff --git a/DevSetup/Public/Use-DevSetup.ps1 b/DevSetup/Public/Use-DevSetup.ps1 index 1080c51..ce85d75 100644 --- a/DevSetup/Public/Use-DevSetup.ps1 +++ b/DevSetup/Public/Use-DevSetup.ps1 @@ -376,7 +376,7 @@ Function Use-DevSetup { $ParameterCopy = [hashtable]$PSBoundParameters $ParameterCopy.Remove('Update') if($_ -eq 'update') { - $ParameterCopy['Latest'] = $true + $ParameterCopy['Version'] = "latest" } Update-DevSetup @ParameterCopy | Out-Null } diff --git a/generateCoverageReport.ps1 b/generateCoverageReport.ps1 index 3e3525b..289b416 100644 --- a/generateCoverageReport.ps1 +++ b/generateCoverageReport.ps1 @@ -1 +1 @@ -& (Join-Path $env:UserProfile '\.dotnet\tools\reportgenerator.exe') -reports:"coverage.xml" -targetdir:"..\reports" -reporttypes:MarkdownSummaryGithub \ No newline at end of file +& (Join-Path $env:UserProfile '\.dotnet\tools\reportgenerator.exe') -sourcedirs:"DevSetup" -reports:"coverage.xml" -targetdir:"..\reports" -reporttypes:MarkdownSummaryGithub \ No newline at end of file diff --git a/preCommit.ps1 b/preCommit.ps1 new file mode 100644 index 0000000..8a014fe --- /dev/null +++ b/preCommit.ps1 @@ -0,0 +1,62 @@ +Import-Module Pester -ErrorAction Stop +$modifiedFiles = (git status -u -s -b | Where-Object { -not ($_ -match "^\s+D") } | Foreach-Object { $_.Substring(3) } | Where-Object { ($_ -match "^DevSetup*") -and -not ($_ -match ".Tests.ps1") }) +if ($modifiedFiles.Count -gt 0) { + Write-Host "The following DevSetup files have been modified:" -ForegroundColor DarkCyan + $modifiedFiles | ForEach-Object { Write-Host "- $_" -ForegroundColor DarkGray } + Write-Host "" + foreach ($file in $modifiedFiles) { + # Check to see if file has a .Tests.ps1 counterpart + $testFile = $file -replace '\.ps1$', '.Tests.ps1' + if (Test-Path $testFile) { + Write-Host "Running tests for $file..." -ForegroundColor DarkCyan + $TestData = ((Invoke-Pester $testFile -CodeCoverage $file -PassThru -Quiet) 2>$null 3>$null 4>$null 5>$null 6>$null) + if($TestData.PassedCount -gt 0) { + $passedColor = "DarkGreen" + } else { + $passedColor = "DarkGray" + } + + if($TestData.FailedCount -gt 0) { + $failedColor = "DarkRed" + } else { + $failedColor = "DarkGray" + } + + if($TestData.SkippedCount -gt 0) { + $skippedColor = "DarkYellow" + } else { + $skippedColor = "DarkGray" + } + + if($TestData.Failed) { + $TestData.Failed | ForEach-Object { Write-Host $_ -ForegroundColor DarkRed } + } + + Write-Host "Tests Passed: $($TestData.PassedCount)," -NoNewLine -ForegroundColor $passedColor + Write-Host " Failed: $($TestData.FailedCount)," -NoNewLine -ForegroundColor $failedColor + Write-Host " Skipped: $($TestData.SkippedCount)," -NoNewline -ForegroundColor $skippedColor + Write-Host " Inconclusive: $($TestData.InconclusiveCount), NotRun: $($TestData.NotRunCount)" -ForegroundColor DarkGray + $Report = $TestData.CodeCoverage.CoverageReport + $Coverage = $TestData.CodeCoverage.CoveragePercent + $Target = $TestData.CodeCoverage.CoveragePercentTarget + if($null -ne $Coverage -and $null -ne $Target) { + if($Coverage -lt $Target) { + $Color = "DarkRed" + } else { + $Color = "DarkGreen" + } + } else { + $Color = "DarkGray" + } + if($Report) { + $Report -Split "`n" | Select-Object -First 1 | Foreach-Object { Write-Host $_ -ForegroundColor $Color } + $Report -Split "`n" | Select-Object -Skip 1 | ForEach-Object { Write-Host $_ -ForegroundColor Gray } + } + } else { + Write-Host "No tests found for $file`n" -ForegroundColor DarkRed + } + } + +} else { + Write-Host "No modified DevSetup files detected." +} \ No newline at end of file diff --git a/runTests.ps1 b/runTests.ps1 index 1a3f140..df3f89f 100644 --- a/runTests.ps1 +++ b/runTests.ps1 @@ -1,6 +1,10 @@ $config = New-PesterConfiguration #$config.Run.PassThru = $true -$config.Run.ExcludePath = @("**/DevSetup.psm1", "**/DevSetup.psd1", "**/Private/Enums/**", "install.ps1", "runTests.ps1", "runSecurity.ps1", "generateDocs.ps1") +$config.Run.Path = "DevSetup" +$config.CodeCoverage.Path = "DevSetup" +$config.CodeCoverage.OutputFormat = "JaCoCo" +$config.CodeCoverage.OutputPath = "coverage.xml" +$config.Output.Verbosity = "Minimal" $config.CodeCoverage.Enabled = $true $config.TestResult.Enabled = $true #$config.Output.Verbosity = "GithubActions"