From c9217307033d1aaabf11949df1c4459ff699c185 Mon Sep 17 00:00:00 2001 From: Jeffrey <9938813+Raphire@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:14:09 +0200 Subject: [PATCH] Enhance output documentation and error handling --- Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 | 14 ++++- Scripts/AppRemoval/Remove-SelectedApps.ps1 | 50 ++++++++++----- Scripts/Features/Import-RegistryFile.ps1 | 8 ++- Scripts/Features/Invoke-Changes.ps1 | 9 ++- .../Features/Invoke-SystemRestorePoint.ps1 | 7 +++ Scripts/Features/Replace-StartMenu.ps1 | 6 ++ .../Features/Set-StoreSearchSuggestions.ps1 | 12 ++++ Scripts/Features/Telemetry-ScheduledTasks.ps1 | 6 ++ Scripts/Features/Windows-OptionalFeatures.ps1 | 16 ++++- Scripts/GUI/MainWindow-AppSelection.ps1 | 4 +- Scripts/GUI/Show-ApplyModal.ps1 | 5 +- Scripts/GUI/Show-MainWindow.ps1 | 12 ++-- Scripts/Helpers/Apply-RegistryRegFile.ps1 | 7 +++ Scripts/Helpers/Test-ConfigConsistency.ps1 | 49 +++++++++++++-- Tests/Import-ConfigToParams.Tests.ps1 | 24 ++++++++ Tests/MainWindow-AppSelection.Tests.ps1 | 4 +- Tests/Remove-SelectedApps.Tests.ps1 | 61 +++++++++++-------- 17 files changed, 228 insertions(+), 66 deletions(-) diff --git a/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 b/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 index 2573975..c7041be 100644 --- a/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 +++ b/Scripts/AppRemoval/Invoke-ForceRemoveEdge.ps1 @@ -1,6 +1,9 @@ <# .SYNOPSIS Forcefully uninstalls Microsoft Edge and removes its leftover shortcuts and autostart entries. + + .OUTPUTS + System.Boolean. $true when Edge is uninstalled and cleanup succeeds; otherwise $false. #> function Invoke-ForceRemoveEdge { if ($script:Params.ContainsKey("WhatIf")) { @@ -13,7 +16,8 @@ function Invoke-ForceRemoveEdge { $regView = [Microsoft.Win32.RegistryView]::Registry32 $hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView) - $hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev').SetValue('AllowUninstall', '') + $edgeUpdateKey = $hklm.CreateSubKey('SOFTWARE\Microsoft\EdgeUpdateDev') + $edgeUpdateKey.SetValue('AllowUninstall', '') # Create stub (This somehow allows uninstalling Edge) $edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe" @@ -95,11 +99,19 @@ function Invoke-ForceRemoveEdge { return $false } finally { + if ($edgeUpdateKey) { $edgeUpdateKey.Dispose() } if ($uninstallRegKey) { $uninstallRegKey.Dispose() } if ($hklm) { $hklm.Dispose() } } } +<# + .SYNOPSIS + Removes an Edge autostart registry value when it exists. + + .OUTPUTS + System.Boolean. $true when the value is absent or removed; $false when inspection or removal fails. +#> function Remove-EdgeAutostartValue { param( [Parameter(Mandatory)] diff --git a/Scripts/AppRemoval/Remove-SelectedApps.ps1 b/Scripts/AppRemoval/Remove-SelectedApps.ps1 index 19f4f13..f5e0c75 100644 --- a/Scripts/AppRemoval/Remove-SelectedApps.ps1 +++ b/Scripts/AppRemoval/Remove-SelectedApps.ps1 @@ -18,6 +18,9 @@ .EXAMPLE Remove-SelectedApps -appsList (Generate-AppsList) + + .OUTPUTS + System.Boolean. $true when all removals can be confirmed; otherwise $false. #> function Remove-SelectedApps { param ( @@ -53,10 +56,11 @@ function Remove-SelectedApps { Write-Host "Removing $app" if ((Get-AppRemovalMethod $app) -eq 'WinGet') { - # WinGet exit codes are not a reliable removal outcome. The single - # post-removal inventory check below determines final success. - $null = Remove-WinGetApp -app $app + $removalSucceeded = Remove-WinGetApp -app $app $wingetRemovedApps += $app + if (($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep')) -and -not $removalSucceeded) { + $wingetRemovalFailures[$app] = $true + } } else { if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) { @@ -116,8 +120,12 @@ function Remove-SelectedApps { .DESCRIPTION Runs winget uninstall for a single app, with a bounded execution time. - If the User or Sysprep parameter was passed, also schedules removal for - future logins. + WinGet's own exit code/success reporting is unreliable and is only logged + for diagnostics; it never causes this function to report failure. Callers + verify removal with a post-removal inventory check instead. This function + only reports failure when the winget invocation itself throws a terminating + error (e.g. it times out or cannot be started). If the User or Sysprep + parameter was passed, also schedules removal for future logins. .PARAMETER app The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews'). @@ -125,6 +133,10 @@ function Remove-SelectedApps { .PARAMETER TimeoutSeconds Maximum time to allow the foreground WinGet uninstall to run. Defaults to 120 seconds. + + .OUTPUTS + System.Boolean. $true unless the winget invocation threw a terminating error + or any required RunOnce scheduling failed; otherwise $false. #> function Remove-WinGetApp { param( @@ -137,27 +149,23 @@ function Remove-WinGetApp { return $false } - $uninstallSucceeded = $true + $uninstallCommandSucceeded = $true + $exitCode = $null try { $uninstallResult = Invoke-NonBlocking -ScriptBlock { param($appId) $output = @(& winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1) - $exitCode = $LASTEXITCODE return [PSCustomObject]@{ - Success = ($exitCode -eq 0) - ExitCode = $exitCode + ExitCode = $LASTEXITCODE Output = $output } } -ArgumentList $app -TimeoutSeconds $TimeoutSeconds - $uninstallSucceeded = [bool]($uninstallResult -and $uninstallResult.Success) Write-WinGetUninstallOutput -Output $(if ($uninstallResult) { $uninstallResult.Output } else { $null }) - if (-not $uninstallSucceeded) { - $exitCode = if ($uninstallResult) { $uninstallResult.ExitCode } else { 'unknown' } - Write-Verbose "WinGet uninstall for $app returned exit code $exitCode. The post-removal inventory check will determine whether the app is still installed." - } + $exitCode = if ($uninstallResult) { $uninstallResult.ExitCode } else { 'unknown' } + Write-Verbose "WinGet uninstall for $app returned exit code $exitCode." } catch { - $uninstallSucceeded = $false + $uninstallCommandSucceeded = $false if ($_.Exception.Message -like 'Operation timed out after *') { Write-Verbose "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_" } @@ -176,9 +184,16 @@ function Remove-WinGetApp { $scheduleSucceeded = Set-RunOnceWingetTask -appId $app } - return ($uninstallSucceeded -and $scheduleSucceeded) + return ($uninstallCommandSucceeded -and $scheduleSucceeded) } +<# + .SYNOPSIS + Writes captured WinGet uninstall output to the verbose stream. + + .OUTPUTS + None. +#> function Write-WinGetUninstallOutput { param( [object[]]$Output @@ -308,6 +323,9 @@ function Get-AppRemovalMethod { following all winget uninstall attempts. In GUI mode, displays a warning message box; in CLI mode, prompts via Read-Host. On confirmation, performs a force-remove of the Edge package. + + .OUTPUTS + System.Boolean. $true when Edge is forcefully removed; otherwise $false. #> function Request-EdgeForceRemove { if ($script:GuiWindow) { diff --git a/Scripts/Features/Import-RegistryFile.ps1 b/Scripts/Features/Import-RegistryFile.ps1 index 92403f6..eccdcae 100644 --- a/Scripts/Features/Import-RegistryFile.ps1 +++ b/Scripts/Features/Import-RegistryFile.ps1 @@ -1,4 +1,10 @@ -# Import & execute regfile +<# + .SYNOPSIS + Imports and executes a registry file. + + .OUTPUTS + System.Boolean. $true when the registry file is applied or previewed successfully; otherwise $false. +#> function Import-RegistryFile { param ( $message, diff --git a/Scripts/Features/Invoke-Changes.ps1 b/Scripts/Features/Invoke-Changes.ps1 index f3bdb65..d989451 100644 --- a/Scripts/Features/Invoke-Changes.ps1 +++ b/Scripts/Features/Invoke-Changes.ps1 @@ -198,11 +198,11 @@ function Invoke-FeatureUndo { return $false } 'EnableWindowsSandbox' { - Write-Host "> $($feature.ApplyUndoText)..." + Write-Host "> $undoText..." return (Disable-WindowsFeature 'Containers-DisposableClientVM') } 'EnableWindowsSubsystemForLinux' { - Write-Host "> $($feature.ApplyUndoText)..." + Write-Host "> $undoText..." if (-not (Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux')) { return $false } return (Disable-WindowsFeature 'VirtualMachinePlatform') } @@ -271,7 +271,10 @@ function Invoke-ApplyFeatures { & $script:ApplyProgressCallback $step $TotalSteps $displayName } - if (-not (Invoke-FeatureApply -FeatureId $featureId)) { + # Compare app-removal failure counts so a feature that only fails due to + # app removal isn't also double-reported as a feature failure. + $appRemovalFailuresBefore = $script:AppRemovalFailures + if ((-not (Invoke-FeatureApply -FeatureId $featureId)) -and ($script:AppRemovalFailures -eq $appRemovalFailuresBefore)) { $script:FeatureFailures++ } Write-Host "" diff --git a/Scripts/Features/Invoke-SystemRestorePoint.ps1 b/Scripts/Features/Invoke-SystemRestorePoint.ps1 index ed87b9e..caa46b3 100644 --- a/Scripts/Features/Invoke-SystemRestorePoint.ps1 +++ b/Scripts/Features/Invoke-SystemRestorePoint.ps1 @@ -1,3 +1,10 @@ +<# + .SYNOPSIS + Creates a system restore point. + + .OUTPUTS + System.Boolean. $true when a restore point is created; otherwise $false. +#> function Invoke-SystemRestorePoint { $failed = $false $isSilent = ($script:Params -and $script:Params.ContainsKey('Silent')) -or $script:Silent diff --git a/Scripts/Features/Replace-StartMenu.ps1 b/Scripts/Features/Replace-StartMenu.ps1 index accd9bf..bbc54dd 100644 --- a/Scripts/Features/Replace-StartMenu.ps1 +++ b/Scripts/Features/Replace-StartMenu.ps1 @@ -18,6 +18,9 @@ .EXAMPLE Replace-StartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin" + + .OUTPUTS + System.Boolean. $true when all resolved profiles are updated or the change is previewed; otherwise $false. #> function Replace-StartMenuForAllUsers { param ( @@ -99,6 +102,9 @@ function Replace-StartMenuForAllUsers { .EXAMPLE Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin" + + .OUTPUTS + System.Boolean. $true when the template is valid and copied, or the change is previewed; otherwise $false. #> function Replace-StartMenu { param ( diff --git a/Scripts/Features/Set-StoreSearchSuggestions.ps1 b/Scripts/Features/Set-StoreSearchSuggestions.ps1 index 44a51e1..0dd6b13 100644 --- a/Scripts/Features/Set-StoreSearchSuggestions.ps1 +++ b/Scripts/Features/Set-StoreSearchSuggestions.ps1 @@ -10,6 +10,9 @@ .EXAMPLE DisableStoreSearchSuggestionsForAllUsers + + .OUTPUTS + System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false. #> function Set-StoreSearchSuggestionsDisabledForAllUsers { $success = $true @@ -60,6 +63,9 @@ function Set-StoreSearchSuggestionsDisabledForAllUsers { .EXAMPLE DisableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" + + .OUTPUTS + System.Boolean. $true when the database ACL is restricted or previewed; otherwise $false. #> function Set-StoreSearchSuggestionsDisabled { param ( @@ -115,6 +121,9 @@ function Set-StoreSearchSuggestionsDisabled { .EXAMPLE EnableStoreSearchSuggestionsForAllUsers + + .OUTPUTS + System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false. #> function Set-StoreSearchSuggestionsEnabledForAllUsers { $success = $true @@ -164,6 +173,9 @@ function Set-StoreSearchSuggestionsEnabledForAllUsers { .EXAMPLE EnableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" + + .OUTPUTS + System.Boolean. $true when the deny ACL is removed, the database is absent, or the change is previewed; otherwise $false. #> function Set-StoreSearchSuggestionsEnabled { param ( diff --git a/Scripts/Features/Telemetry-ScheduledTasks.ps1 b/Scripts/Features/Telemetry-ScheduledTasks.ps1 index 3f50ac5..c77aa49 100644 --- a/Scripts/Features/Telemetry-ScheduledTasks.ps1 +++ b/Scripts/Features/Telemetry-ScheduledTasks.ps1 @@ -34,6 +34,9 @@ function Get-TelemetryScheduledTasks { .EXAMPLE Disable-TelemetryScheduledTasks + + .OUTPUTS + System.Boolean. $true when every task is disabled, absent, already disabled, or previewed; otherwise $false. #> function Disable-TelemetryScheduledTasks { Write-Host "> Disabling telemetry scheduled tasks..." @@ -97,6 +100,9 @@ function Disable-TelemetryScheduledTasks { .EXAMPLE Enable-TelemetryScheduledTasks + + .OUTPUTS + System.Boolean. $true when every task is enabled, absent, already enabled, or previewed; otherwise $false. #> function Enable-TelemetryScheduledTasks { Write-Host "> Enabling telemetry scheduled tasks..." diff --git a/Scripts/Features/Windows-OptionalFeatures.ps1 b/Scripts/Features/Windows-OptionalFeatures.ps1 index 1949906..9ccc9be 100644 --- a/Scripts/Features/Windows-OptionalFeatures.ps1 +++ b/Scripts/Features/Windows-OptionalFeatures.ps1 @@ -1,4 +1,10 @@ -# Enables a Windows optional feature and pipes its output to the console +<# + .SYNOPSIS + Enables a Windows optional feature and pipes its output to the console. + + .OUTPUTS + System.Boolean. $true when enabling succeeds or is previewed; otherwise $false. +#> function Enable-WindowsFeature { param ( [string]$FeatureName @@ -44,7 +50,13 @@ function Enable-WindowsFeature { return $true } -# Disables a Windows optional feature and pipes its output to the console +<# + .SYNOPSIS + Disables a Windows optional feature and pipes its output to the console. + + .OUTPUTS + System.Boolean. $true when disabling succeeds or is previewed; otherwise $false. +#> function Disable-WindowsFeature { param ( [string]$FeatureName diff --git a/Scripts/GUI/MainWindow-AppSelection.ps1 b/Scripts/GUI/MainWindow-AppSelection.ps1 index e1ff603..16baff0 100644 --- a/Scripts/GUI/MainWindow-AppSelection.ps1 +++ b/Scripts/GUI/MainWindow-AppSelection.ps1 @@ -234,8 +234,8 @@ function Get-AppRemovalScopeTarget { "AppRemovalScopeAllUsers" { return 'AllUsers' } "AppRemovalScopeCurrentUser" { return 'CurrentUser' } default { - Write-Warning "Unrecognized app-removal scope item '$($selectedItem.Name)'. Defaulting to AllUsers." - return 'AllUsers' + Write-Warning "Unrecognized app-removal scope item '$($selectedItem.Name)'. Skipping app removal." + return $null } } } diff --git a/Scripts/GUI/Show-ApplyModal.ps1 b/Scripts/GUI/Show-ApplyModal.ps1 index f4870d7..ab296e5 100644 --- a/Scripts/GUI/Show-ApplyModal.ps1 +++ b/Scripts/GUI/Show-ApplyModal.ps1 @@ -114,8 +114,7 @@ function Show-ApplyModal { try { Invoke-AllChanges - $featureFailureCount = [int]$script:FeatureFailures - $failureCount = $featureFailureCount + $failureCount = [int]$script:FeatureFailures + [int]$script:AppRemovalFailures $appRemovalVerificationUnavailable = [bool]$script:AppRemovalVerificationUnavailable # Restart explorer if requested @@ -156,7 +155,7 @@ function Show-ApplyModal { } else { $script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors" - $script:ApplyCompletionMessageEl.Text = "$featureFailureCount change(s) failed. See console for details." + $script:ApplyCompletionMessageEl.Text = "$failureCount change(s) failed. See console for details." } } else { Write-Host "All changes have been applied successfully!" diff --git a/Scripts/GUI/Show-MainWindow.ps1 b/Scripts/GUI/Show-MainWindow.ps1 index b4cff19..9987898 100644 --- a/Scripts/GUI/Show-MainWindow.ps1 +++ b/Scripts/GUI/Show-MainWindow.ps1 @@ -670,13 +670,15 @@ function Show-MainWindow { if ($selectedApps.Count -gt 0) { if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return } + $scopeTarget = Get-AppRemovalScopeTarget -AppRemovalScopeCombo $appRemovalScopeCombo -OtherUsernameTextBox $otherUsernameTextBox + if ([string]::IsNullOrWhiteSpace($scopeTarget)) { + Write-Warning 'App removal was cancelled because the selected removal scope is invalid.' + return + } + Add-Parameter 'RemoveApps' Add-Parameter 'Apps' ($selectedApps -join ',') - - $scopeTarget = Get-AppRemovalScopeTarget -AppRemovalScopeCombo $appRemovalScopeCombo -OtherUsernameTextBox $otherUsernameTextBox - if (-not [string]::IsNullOrWhiteSpace($scopeTarget)) { - Add-Parameter 'AppRemovalTarget' $scopeTarget - } + Add-Parameter 'AppRemovalTarget' $scopeTarget } # Apply dynamic tweaks diff --git a/Scripts/Helpers/Apply-RegistryRegFile.ps1 b/Scripts/Helpers/Apply-RegistryRegFile.ps1 index bc89532..7be9996 100644 --- a/Scripts/Helpers/Apply-RegistryRegFile.ps1 +++ b/Scripts/Helpers/Apply-RegistryRegFile.ps1 @@ -191,6 +191,13 @@ function Invoke-RegistryOperation { } } +<# + .SYNOPSIS + Applies all parsed operations from a registry file. + + .OUTPUTS + System.Boolean. $true when all operations complete, including WhatIf; otherwise $false. +#> function Invoke-RegistryOperationsFromRegFile { param( [Parameter(Mandatory)] diff --git a/Scripts/Helpers/Test-ConfigConsistency.ps1 b/Scripts/Helpers/Test-ConfigConsistency.ps1 index ffa41e0..6fd4352 100644 --- a/Scripts/Helpers/Test-ConfigConsistency.ps1 +++ b/Scripts/Helpers/Test-ConfigConsistency.ps1 @@ -25,6 +25,33 @@ function Test-ConfigConsistency { return 'The configuration file contains no importable data.' } + if ($null -ne $Config.Apps) { + if ($Config.Apps -isnot [string] -and $Config.Apps -isnot [System.Collections.IEnumerable]) { + return 'Configuration Apps entries must be strings.' + } + foreach ($app in @($Config.Apps)) { + if ($app -isnot [string]) { + return 'Configuration Apps entries must be strings.' + } + } + } + + foreach ($categoryName in @('Tweaks', 'Deployment')) { + $category = $Config.$categoryName + if ($null -eq $category) { continue } + + if ($category -is [string] -or $category -isnot [System.Collections.IEnumerable]) { + return "Configuration $categoryName entries must contain Name and Value properties." + } + foreach ($setting in @($category)) { + $hasName = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Name') } else { $null -ne $setting.PSObject.Properties['Name'] } + $hasValue = if ($setting -is [System.Collections.IDictionary]) { $setting.Contains('Value') } else { $null -ne $setting.PSObject.Properties['Value'] } + if (-not $setting -or -not $hasName -or -not $hasValue -or $setting.Name -isnot [string] -or [string]::IsNullOrWhiteSpace($setting.Name)) { + return "Configuration $categoryName entries must contain Name and Value properties." + } + } + } + $lookup = @{} foreach ($setting in @($Config.Deployment)) { if ($setting -and $setting.Name) { @@ -35,16 +62,30 @@ function Test-ConfigConsistency { $hasScope = $lookup.ContainsKey('AppRemovalScopeIndex') $hasUser = $lookup.ContainsKey('UserSelectionIndex') + $scopeIndex = $null + if ($hasScope) { + if (-not [int]::TryParse("$($lookup['AppRemovalScopeIndex'])", [ref]$scopeIndex) -or $scopeIndex -notin @(0, 1, 2)) { + return 'AppRemovalScopeIndex must be a supported numeric value (0, 1, or 2).' + } + } + + $userIndex = $null + if ($hasUser) { + if (-not [int]::TryParse("$($lookup['UserSelectionIndex'])", [ref]$userIndex) -or $userIndex -notin @(0, 1, 2)) { + return 'UserSelectionIndex must be a supported numeric value (0, 1, or 2).' + } + } + # "Current user only" (index 1) is only valid together with "Current User" (index 0) - if ($hasScope -and [int]$lookup['AppRemovalScopeIndex'] -eq 1) { - if (-not $hasUser -or [int]$lookup['UserSelectionIndex'] -ne 0) { + if ($hasScope -and $scopeIndex -eq 1) { + if (-not $hasUser -or $userIndex -ne 0) { return "App removal scope 'Current user only' (AppRemovalScopeIndex 1) requires the deployment target 'Current User' (UserSelectionIndex 0)." } } # "Target user only" (index 2) is only valid together with "Other User" (index 1) - if ($hasScope -and [int]$lookup['AppRemovalScopeIndex'] -eq 2) { - if (-not $hasUser -or [int]$lookup['UserSelectionIndex'] -ne 1) { + if ($hasScope -and $scopeIndex -eq 2) { + if (-not $hasUser -or $userIndex -ne 1) { return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires the deployment target 'Other User' (UserSelectionIndex 1)." } if (-not $lookup.ContainsKey('OtherUsername') -or [string]::IsNullOrWhiteSpace("$($lookup['OtherUsername'])")) { diff --git a/Tests/Import-ConfigToParams.Tests.ps1 b/Tests/Import-ConfigToParams.Tests.ps1 index c6c2077..a80f45d 100644 --- a/Tests/Import-ConfigToParams.Tests.ps1 +++ b/Tests/Import-ConfigToParams.Tests.ps1 @@ -61,6 +61,30 @@ Describe 'Test-ConfigConsistency' { Test-ConfigConsistency -Config $config | Should -Match 'no importable data' } + It 'reports an error for invalid app entries' { + $config = [PSCustomObject]@{ Version = '1.0'; Apps = 42 } + + Test-ConfigConsistency -Config $config | Should -Match 'Apps entries must be strings' + } + + It 'reports an error for nonnumeric deployment indexes' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @(@{ Name = 'AppRemovalScopeIndex'; Value = 'all' }) + } + + Test-ConfigConsistency -Config $config | Should -Match 'AppRemovalScopeIndex must be a supported numeric value' + } + + It 'reports an error for out-of-range deployment indexes' { + $config = [PSCustomObject]@{ + Version = '1.0' + Deployment = @(@{ Name = 'UserSelectionIndex'; Value = 3 }) + } + + Test-ConfigConsistency -Config $config | Should -Match 'UserSelectionIndex must be a supported numeric value' + } + It 'returns null for a consistent all-users scope' { $config = [PSCustomObject]@{ Version = '1.0' diff --git a/Tests/MainWindow-AppSelection.Tests.ps1 b/Tests/MainWindow-AppSelection.Tests.ps1 index f6a3b99..50c32c7 100644 --- a/Tests/MainWindow-AppSelection.Tests.ps1 +++ b/Tests/MainWindow-AppSelection.Tests.ps1 @@ -211,7 +211,7 @@ Describe 'Get-AppRemovalScopeTarget' { Get-AppRemovalScopeTarget -AppRemovalScopeCombo $combo -OtherUsernameTextBox $usernameBox | Should -BeNullOrEmpty } - It 'defaults to AllUsers for an unrecognized ComboBoxItem Name' { + It 'returns null for an unrecognized ComboBoxItem Name' { $combo = New-Object System.Windows.Controls.ComboBox $item = New-Object System.Windows.Controls.ComboBoxItem $item.Name = 'SomeUnrelatedControl' @@ -219,7 +219,7 @@ Describe 'Get-AppRemovalScopeTarget' { $combo.SelectedItem = $item $usernameBox = New-Object System.Windows.Controls.TextBox - Get-AppRemovalScopeTarget -AppRemovalScopeCombo $combo -OtherUsernameTextBox $usernameBox | Should -Be 'AllUsers' + Get-AppRemovalScopeTarget -AppRemovalScopeCombo $combo -OtherUsernameTextBox $usernameBox | Should -BeNullOrEmpty } It 'returns an empty string for the target-user scope when the username is blank' { diff --git a/Tests/Remove-SelectedApps.Tests.ps1 b/Tests/Remove-SelectedApps.Tests.ps1 index 883b0f2..5889ba4 100644 --- a/Tests/Remove-SelectedApps.Tests.ps1 +++ b/Tests/Remove-SelectedApps.Tests.ps1 @@ -91,6 +91,17 @@ Describe 'Remove-SelectedApps' { $script:AppRemovalFailures | Should -Be 0 } + It 'counts a failed WinGet scheduling result for a target user' { + $script:Params = @{ User = 'Alice' } + Mock Get-AppRemovalMethod { 'WinGet' } + Mock Remove-WinGetApp { $false } + Mock Test-AppInWingetList { $false } + + Remove-SelectedApps -appsList @('One.App') | Should -BeFalse + + $script:AppRemovalFailures | Should -Be 1 + } + It 'counts a WinGet removal that remains installed after a successful command' { Mock Get-AppRemovalMethod { 'WinGet' } Mock Test-AppInWingetList { $true } @@ -197,41 +208,28 @@ Describe 'Remove-WinGetApp' { } } - It 'returns false when winget exits unsuccessfully' { - Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $false; ExitCode = 1; Output = @('failure') } } - Mock Write-Verbose {} - - Remove-WinGetApp -app 'One.App' | Should -BeFalse - - Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -match 'exit code 1' } - } - - It 'returns false for a non-zero WinGet exit code without writing an error record' { - Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $false; ExitCode = -1978335212; Output = @('No installed package found matching input criteria.') } } - Mock Write-Verbose {} - - Remove-WinGetApp -app 'One.App' | Should -BeFalse - - Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -match 'post-removal inventory check' } - Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -match 'No installed package found' } - } - - It 'writes captured winget output to the verbose stream before returning success' { - Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $true; ExitCode = 0; Output = @('Successfully uninstalled One.App') } } + It 'returns true and logs WinGet output regardless of exit code' -ForEach @( + @{ ExitCode = 0; Output = 'Successfully uninstalled One.App' } + @{ ExitCode = 1; Output = 'Package was not found' } + @{ ExitCode = -1978335212; Output = 'No installed package found matching input criteria.' } + ) { + $script:expectedExitCode = $ExitCode + $script:expectedOutput = $Output + Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = $script:expectedExitCode; Output = @($script:expectedOutput) } } Mock Write-Verbose {} Remove-WinGetApp -app 'One.App' | Should -BeTrue - Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -eq 'Successfully uninstalled One.App' } + Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -eq $script:expectedOutput } + Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -eq "WinGet uninstall for One.App returned exit code $script:expectedExitCode." } } - It 'writes captured winget diagnostics to the verbose stream when winget fails' { - Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $false; ExitCode = 1; Output = @('Package was not found') } } - Mock Write-Verbose {} + It 'returns the RunOnce scheduling result for a target user' { + $script:Params = @{ User = 'Alice' } + Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = 1; Output = @() } } + Mock Set-RunOnceWingetTask { $false } Remove-WinGetApp -app 'One.App' | Should -BeFalse - - Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -eq 'Package was not found' } } } @@ -250,6 +248,15 @@ Describe 'Remove-EdgeAutostartValue' { Should -Invoke Remove-ItemProperty -Times 0 -Exactly } + It 'treats a missing registry key as already cleaned up' { + Mock Get-ItemProperty { throw [System.Management.Automation.ItemNotFoundException]::new('not found') } + Mock Remove-ItemProperty {} + + Remove-EdgeAutostartValue -Path 'HKCU:\Software\Example' -Name 'Microsoft Edge Update' | Should -BeTrue + + Should -Invoke Remove-ItemProperty -Times 0 -Exactly + } + It 'removes an existing value' { Mock Get-ItemProperty { [PSCustomObject]@{ 'Microsoft Edge Update' = 'enabled' } } Mock Remove-ItemProperty {}