Enhance output documentation and error handling

This commit is contained in:
Jeffrey
2026-08-22 17:14:09 +02:00
parent 80eadd531c
commit c921730703
17 changed files with 228 additions and 66 deletions
+13 -1
View File
@@ -1,6 +1,9 @@
<# <#
.SYNOPSIS .SYNOPSIS
Forcefully uninstalls Microsoft Edge and removes its leftover shortcuts and autostart entries. 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 { function Invoke-ForceRemoveEdge {
if ($script:Params.ContainsKey("WhatIf")) { if ($script:Params.ContainsKey("WhatIf")) {
@@ -13,7 +16,8 @@ function Invoke-ForceRemoveEdge {
$regView = [Microsoft.Win32.RegistryView]::Registry32 $regView = [Microsoft.Win32.RegistryView]::Registry32
$hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $regView) $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) # Create stub (This somehow allows uninstalling Edge)
$edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe" $edgeStub = "$env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe"
@@ -95,11 +99,19 @@ function Invoke-ForceRemoveEdge {
return $false return $false
} }
finally { finally {
if ($edgeUpdateKey) { $edgeUpdateKey.Dispose() }
if ($uninstallRegKey) { $uninstallRegKey.Dispose() } if ($uninstallRegKey) { $uninstallRegKey.Dispose() }
if ($hklm) { $hklm.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 { function Remove-EdgeAutostartValue {
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
+33 -15
View File
@@ -18,6 +18,9 @@
.EXAMPLE .EXAMPLE
Remove-SelectedApps -appsList (Generate-AppsList) Remove-SelectedApps -appsList (Generate-AppsList)
.OUTPUTS
System.Boolean. $true when all removals can be confirmed; otherwise $false.
#> #>
function Remove-SelectedApps { function Remove-SelectedApps {
param ( param (
@@ -53,10 +56,11 @@ function Remove-SelectedApps {
Write-Host "Removing $app" Write-Host "Removing $app"
if ((Get-AppRemovalMethod $app) -eq 'WinGet') { if ((Get-AppRemovalMethod $app) -eq 'WinGet') {
# WinGet exit codes are not a reliable removal outcome. The single $removalSucceeded = Remove-WinGetApp -app $app
# post-removal inventory check below determines final success.
$null = Remove-WinGetApp -app $app
$wingetRemovedApps += $app $wingetRemovedApps += $app
if (($script:Params.ContainsKey('User') -or $script:Params.ContainsKey('Sysprep')) -and -not $removalSucceeded) {
$wingetRemovalFailures[$app] = $true
}
} }
else { else {
if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) { if (-not (Remove-AppxApp -app $app -targetUser $targetUser)) {
@@ -116,8 +120,12 @@ function Remove-SelectedApps {
.DESCRIPTION .DESCRIPTION
Runs winget uninstall for a single app, with a bounded execution time. Runs winget uninstall for a single app, with a bounded execution time.
If the User or Sysprep parameter was passed, also schedules removal for WinGet's own exit code/success reporting is unreliable and is only logged
future logins. 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 .PARAMETER app
The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews'). The WinGet package ID to uninstall (e.g. 'Microsoft.BingNews').
@@ -125,6 +133,10 @@ function Remove-SelectedApps {
.PARAMETER TimeoutSeconds .PARAMETER TimeoutSeconds
Maximum time to allow the foreground WinGet uninstall to run. Defaults Maximum time to allow the foreground WinGet uninstall to run. Defaults
to 120 seconds. 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 { function Remove-WinGetApp {
param( param(
@@ -137,27 +149,23 @@ function Remove-WinGetApp {
return $false return $false
} }
$uninstallSucceeded = $true $uninstallCommandSucceeded = $true
$exitCode = $null
try { try {
$uninstallResult = Invoke-NonBlocking -ScriptBlock { $uninstallResult = Invoke-NonBlocking -ScriptBlock {
param($appId) param($appId)
$output = @(& winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1) $output = @(& winget uninstall --accept-source-agreements --disable-interactivity --id $appId 2>&1)
$exitCode = $LASTEXITCODE
return [PSCustomObject]@{ return [PSCustomObject]@{
Success = ($exitCode -eq 0) ExitCode = $LASTEXITCODE
ExitCode = $exitCode
Output = $output Output = $output
} }
} -ArgumentList $app -TimeoutSeconds $TimeoutSeconds } -ArgumentList $app -TimeoutSeconds $TimeoutSeconds
$uninstallSucceeded = [bool]($uninstallResult -and $uninstallResult.Success)
Write-WinGetUninstallOutput -Output $(if ($uninstallResult) { $uninstallResult.Output } else { $null }) Write-WinGetUninstallOutput -Output $(if ($uninstallResult) { $uninstallResult.Output } else { $null })
if (-not $uninstallSucceeded) {
$exitCode = if ($uninstallResult) { $uninstallResult.ExitCode } else { 'unknown' } $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." Write-Verbose "WinGet uninstall for $app returned exit code $exitCode."
}
} }
catch { catch {
$uninstallSucceeded = $false $uninstallCommandSucceeded = $false
if ($_.Exception.Message -like 'Operation timed out after *') { if ($_.Exception.Message -like 'Operation timed out after *') {
Write-Verbose "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_" Write-Verbose "WinGet uninstall for $app did not complete within $TimeoutSeconds seconds: $_"
} }
@@ -176,9 +184,16 @@ function Remove-WinGetApp {
$scheduleSucceeded = Set-RunOnceWingetTask -appId $app $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 { function Write-WinGetUninstallOutput {
param( param(
[object[]]$Output [object[]]$Output
@@ -308,6 +323,9 @@ function Get-AppRemovalMethod {
following all winget uninstall attempts. In GUI mode, displays a following all winget uninstall attempts. In GUI mode, displays a
warning message box; in CLI mode, prompts via Read-Host. On warning message box; in CLI mode, prompts via Read-Host. On
confirmation, performs a force-remove of the Edge package. confirmation, performs a force-remove of the Edge package.
.OUTPUTS
System.Boolean. $true when Edge is forcefully removed; otherwise $false.
#> #>
function Request-EdgeForceRemove { function Request-EdgeForceRemove {
if ($script:GuiWindow) { if ($script:GuiWindow) {
+7 -1
View File
@@ -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 { function Import-RegistryFile {
param ( param (
$message, $message,
+6 -3
View File
@@ -198,11 +198,11 @@ function Invoke-FeatureUndo {
return $false return $false
} }
'EnableWindowsSandbox' { 'EnableWindowsSandbox' {
Write-Host "> $($feature.ApplyUndoText)..." Write-Host "> $undoText..."
return (Disable-WindowsFeature 'Containers-DisposableClientVM') return (Disable-WindowsFeature 'Containers-DisposableClientVM')
} }
'EnableWindowsSubsystemForLinux' { 'EnableWindowsSubsystemForLinux' {
Write-Host "> $($feature.ApplyUndoText)..." Write-Host "> $undoText..."
if (-not (Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux')) { return $false } if (-not (Disable-WindowsFeature 'Microsoft-Windows-Subsystem-Linux')) { return $false }
return (Disable-WindowsFeature 'VirtualMachinePlatform') return (Disable-WindowsFeature 'VirtualMachinePlatform')
} }
@@ -271,7 +271,10 @@ function Invoke-ApplyFeatures {
& $script:ApplyProgressCallback $step $TotalSteps $displayName & $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++ $script:FeatureFailures++
} }
Write-Host "" Write-Host ""
@@ -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 { function Invoke-SystemRestorePoint {
$failed = $false $failed = $false
$isSilent = ($script:Params -and $script:Params.ContainsKey('Silent')) -or $script:Silent $isSilent = ($script:Params -and $script:Params.ContainsKey('Silent')) -or $script:Silent
+6
View File
@@ -18,6 +18,9 @@
.EXAMPLE .EXAMPLE
Replace-StartMenuForAllUsers -startMenuTemplate "C:\CustomLayout.bin" 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 { function Replace-StartMenuForAllUsers {
param ( param (
@@ -99,6 +102,9 @@ function Replace-StartMenuForAllUsers {
.EXAMPLE .EXAMPLE
Replace-StartMenu -startMenuBinFile "$env:LOCALAPPDATA\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin" -startMenuTemplate "C:\CustomLayout.bin" 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 { function Replace-StartMenu {
param ( param (
@@ -10,6 +10,9 @@
.EXAMPLE .EXAMPLE
DisableStoreSearchSuggestionsForAllUsers DisableStoreSearchSuggestionsForAllUsers
.OUTPUTS
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
#> #>
function Set-StoreSearchSuggestionsDisabledForAllUsers { function Set-StoreSearchSuggestionsDisabledForAllUsers {
$success = $true $success = $true
@@ -60,6 +63,9 @@ function Set-StoreSearchSuggestionsDisabledForAllUsers {
.EXAMPLE .EXAMPLE
DisableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" 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 { function Set-StoreSearchSuggestionsDisabled {
param ( param (
@@ -115,6 +121,9 @@ function Set-StoreSearchSuggestionsDisabled {
.EXAMPLE .EXAMPLE
EnableStoreSearchSuggestionsForAllUsers EnableStoreSearchSuggestionsForAllUsers
.OUTPUTS
System.Boolean. $true when a profile is processed and all ACL changes succeed; otherwise $false.
#> #>
function Set-StoreSearchSuggestionsEnabledForAllUsers { function Set-StoreSearchSuggestionsEnabledForAllUsers {
$success = $true $success = $true
@@ -164,6 +173,9 @@ function Set-StoreSearchSuggestionsEnabledForAllUsers {
.EXAMPLE .EXAMPLE
EnableStoreSearchSuggestions -StoreAppsDatabase "$env:LOCALAPPDATA\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db" 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 { function Set-StoreSearchSuggestionsEnabled {
param ( param (
@@ -34,6 +34,9 @@ function Get-TelemetryScheduledTasks {
.EXAMPLE .EXAMPLE
Disable-TelemetryScheduledTasks Disable-TelemetryScheduledTasks
.OUTPUTS
System.Boolean. $true when every task is disabled, absent, already disabled, or previewed; otherwise $false.
#> #>
function Disable-TelemetryScheduledTasks { function Disable-TelemetryScheduledTasks {
Write-Host "> Disabling telemetry scheduled tasks..." Write-Host "> Disabling telemetry scheduled tasks..."
@@ -97,6 +100,9 @@ function Disable-TelemetryScheduledTasks {
.EXAMPLE .EXAMPLE
Enable-TelemetryScheduledTasks Enable-TelemetryScheduledTasks
.OUTPUTS
System.Boolean. $true when every task is enabled, absent, already enabled, or previewed; otherwise $false.
#> #>
function Enable-TelemetryScheduledTasks { function Enable-TelemetryScheduledTasks {
Write-Host "> Enabling telemetry scheduled tasks..." Write-Host "> Enabling telemetry scheduled tasks..."
+14 -2
View File
@@ -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 { function Enable-WindowsFeature {
param ( param (
[string]$FeatureName [string]$FeatureName
@@ -44,7 +50,13 @@ function Enable-WindowsFeature {
return $true 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 { function Disable-WindowsFeature {
param ( param (
[string]$FeatureName [string]$FeatureName
+2 -2
View File
@@ -234,8 +234,8 @@ function Get-AppRemovalScopeTarget {
"AppRemovalScopeAllUsers" { return 'AllUsers' } "AppRemovalScopeAllUsers" { return 'AllUsers' }
"AppRemovalScopeCurrentUser" { return 'CurrentUser' } "AppRemovalScopeCurrentUser" { return 'CurrentUser' }
default { default {
Write-Warning "Unrecognized app-removal scope item '$($selectedItem.Name)'. Defaulting to AllUsers." Write-Warning "Unrecognized app-removal scope item '$($selectedItem.Name)'. Skipping app removal."
return 'AllUsers' return $null
} }
} }
} }
+2 -3
View File
@@ -114,8 +114,7 @@ function Show-ApplyModal {
try { try {
Invoke-AllChanges Invoke-AllChanges
$featureFailureCount = [int]$script:FeatureFailures $failureCount = [int]$script:FeatureFailures + [int]$script:AppRemovalFailures
$failureCount = $featureFailureCount
$appRemovalVerificationUnavailable = [bool]$script:AppRemovalVerificationUnavailable $appRemovalVerificationUnavailable = [bool]$script:AppRemovalVerificationUnavailable
# Restart explorer if requested # Restart explorer if requested
@@ -156,7 +155,7 @@ function Show-ApplyModal {
} }
else { else {
$script:ApplyCompletionTitleEl.Text = "Changes Applied with Errors" $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 { } else {
Write-Host "All changes have been applied successfully!" Write-Host "All changes have been applied successfully!"
+6 -4
View File
@@ -670,14 +670,16 @@ function Show-MainWindow {
if ($selectedApps.Count -gt 0) { if ($selectedApps.Count -gt 0) {
if (-not (Confirm-UnsafeAppRemoval -SelectedApps $selectedApps -Owner $window)) { return } 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 'RemoveApps'
Add-Parameter 'Apps' ($selectedApps -join ',') 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 # Apply dynamic tweaks
foreach ($tweakAction in @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$showAppliedTweaksMode)) { foreach ($tweakAction in @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$showAppliedTweaksMode)) {
@@ -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 { function Invoke-RegistryOperationsFromRegFile {
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
+45 -4
View File
@@ -25,6 +25,33 @@ function Test-ConfigConsistency {
return 'The configuration file contains no importable data.' 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 = @{} $lookup = @{}
foreach ($setting in @($Config.Deployment)) { foreach ($setting in @($Config.Deployment)) {
if ($setting -and $setting.Name) { if ($setting -and $setting.Name) {
@@ -35,16 +62,30 @@ function Test-ConfigConsistency {
$hasScope = $lookup.ContainsKey('AppRemovalScopeIndex') $hasScope = $lookup.ContainsKey('AppRemovalScopeIndex')
$hasUser = $lookup.ContainsKey('UserSelectionIndex') $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) # "Current user only" (index 1) is only valid together with "Current User" (index 0)
if ($hasScope -and [int]$lookup['AppRemovalScopeIndex'] -eq 1) { if ($hasScope -and $scopeIndex -eq 1) {
if (-not $hasUser -or [int]$lookup['UserSelectionIndex'] -ne 0) { if (-not $hasUser -or $userIndex -ne 0) {
return "App removal scope 'Current user only' (AppRemovalScopeIndex 1) requires the deployment target 'Current User' (UserSelectionIndex 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) # "Target user only" (index 2) is only valid together with "Other User" (index 1)
if ($hasScope -and [int]$lookup['AppRemovalScopeIndex'] -eq 2) { if ($hasScope -and $scopeIndex -eq 2) {
if (-not $hasUser -or [int]$lookup['UserSelectionIndex'] -ne 1) { if (-not $hasUser -or $userIndex -ne 1) {
return "App removal scope 'Target user only' (AppRemovalScopeIndex 2) requires the deployment target 'Other User' (UserSelectionIndex 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'])")) { if (-not $lookup.ContainsKey('OtherUsername') -or [string]::IsNullOrWhiteSpace("$($lookup['OtherUsername'])")) {
+24
View File
@@ -61,6 +61,30 @@ Describe 'Test-ConfigConsistency' {
Test-ConfigConsistency -Config $config | Should -Match 'no importable data' 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' { It 'returns null for a consistent all-users scope' {
$config = [PSCustomObject]@{ $config = [PSCustomObject]@{
Version = '1.0' Version = '1.0'
+2 -2
View File
@@ -211,7 +211,7 @@ Describe 'Get-AppRemovalScopeTarget' {
Get-AppRemovalScopeTarget -AppRemovalScopeCombo $combo -OtherUsernameTextBox $usernameBox | Should -BeNullOrEmpty 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 $combo = New-Object System.Windows.Controls.ComboBox
$item = New-Object System.Windows.Controls.ComboBoxItem $item = New-Object System.Windows.Controls.ComboBoxItem
$item.Name = 'SomeUnrelatedControl' $item.Name = 'SomeUnrelatedControl'
@@ -219,7 +219,7 @@ Describe 'Get-AppRemovalScopeTarget' {
$combo.SelectedItem = $item $combo.SelectedItem = $item
$usernameBox = New-Object System.Windows.Controls.TextBox $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' { It 'returns an empty string for the target-user scope when the username is blank' {
+34 -27
View File
@@ -91,6 +91,17 @@ Describe 'Remove-SelectedApps' {
$script:AppRemovalFailures | Should -Be 0 $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' { It 'counts a WinGet removal that remains installed after a successful command' {
Mock Get-AppRemovalMethod { 'WinGet' } Mock Get-AppRemovalMethod { 'WinGet' }
Mock Test-AppInWingetList { $true } Mock Test-AppInWingetList { $true }
@@ -197,41 +208,28 @@ Describe 'Remove-WinGetApp' {
} }
} }
It 'returns false when winget exits unsuccessfully' { It 'returns true and logs WinGet output regardless of exit code' -ForEach @(
Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $false; ExitCode = 1; Output = @('failure') } } @{ ExitCode = 0; Output = 'Successfully uninstalled One.App' }
Mock Write-Verbose {} @{ ExitCode = 1; Output = 'Package was not found' }
@{ ExitCode = -1978335212; Output = 'No installed package found matching input criteria.' }
Remove-WinGetApp -app 'One.App' | Should -BeFalse ) {
$script:expectedExitCode = $ExitCode
Should -Invoke Write-Verbose -Times 1 -Exactly -ParameterFilter { $Message -match 'exit code 1' } $script:expectedOutput = $Output
} Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = $script:expectedExitCode; Output = @($script:expectedOutput) } }
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') } }
Mock Write-Verbose {} Mock Write-Verbose {}
Remove-WinGetApp -app 'One.App' | Should -BeTrue 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' { It 'returns the RunOnce scheduling result for a target user' {
Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $false; ExitCode = 1; Output = @('Package was not found') } } $script:Params = @{ User = 'Alice' }
Mock Write-Verbose {} Mock Invoke-NonBlocking { [PSCustomObject]@{ ExitCode = 1; Output = @() } }
Mock Set-RunOnceWingetTask { $false }
Remove-WinGetApp -app 'One.App' | Should -BeFalse 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 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' { It 'removes an existing value' {
Mock Get-ItemProperty { [PSCustomObject]@{ 'Microsoft Edge Update' = 'enabled' } } Mock Get-ItemProperty { [PSCustomObject]@{ 'Microsoft Edge Update' = 'enabled' } }
Mock Remove-ItemProperty {} Mock Remove-ItemProperty {}