Improve registry backup safety and add optional backup skipping (#710)

This commit is contained in:
Jeffrey
2026-07-25 20:05:49 +02:00
committed by GitHub
parent 68cacfce89
commit 32cedaf65d
30 changed files with 1603 additions and 43 deletions
+4 -2
View File
@@ -933,12 +933,14 @@
<StackPanel>
<TextBlock Text="Options" Style="{StaticResource CategoryHeaderTextBlock}"/>
<!-- Restore Point Option -->
<StackPanel>
<CheckBox x:Name="RegistryBackupCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" IsChecked="True" Content="Create a registry backup (Recommended)" AutomationProperties.Name="Create a registry backup (Recommended)"/>
</StackPanel>
<StackPanel>
<CheckBox x:Name="RestorePointCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" IsChecked="True" Content="Create a system restore point (Recommended)" AutomationProperties.Name="Create a system restore point (Recommended)"/>
</StackPanel>
<!-- Restart Explorer Option -->
<StackPanel>
<CheckBox x:Name="RestartExplorerCheckBox" Style="{DynamicResource FeatureCheckboxStyle}" Content="Restart the Windows Explorer process to apply all changes immediately" AutomationProperties.Name="Restart the Windows Explorer process to apply all changes immediately"/>
</StackPanel>
@@ -201,7 +201,8 @@ function Get-RegistryKeySnapshot {
.DESCRIPTION
Captures all values or selected value names, records missing selected values,
and recursively captures subkeys when requested.
and recursively captures subkeys when requested. Throws if a requested subkey
cannot be read.
#>
function Convert-RegistryKeyToSnapshot {
param(
@@ -241,7 +242,9 @@ function Convert-RegistryKeyToSnapshot {
if ($IncludeSubKeys) {
foreach ($subKeyName in @($RegistryKey.GetSubKeyNames())) {
$childKey = $RegistryKey.OpenSubKey($subKeyName, $false)
if ($null -eq $childKey) { continue }
if ($null -eq $childKey) {
throw "Unable to read registry subkey '$($RegistryKey.Name)\$subKeyName' while creating a backup snapshot. The backup was not created."
}
try {
$childPath = if ([string]::IsNullOrWhiteSpace($FullPath)) { $subKeyName } else { "$FullPath\$subKeyName" }
+6 -6
View File
@@ -121,7 +121,7 @@ function Invoke-FeatureApply {
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey("Sysprep")) {
Write-Host "> Disabling Microsoft Store search suggestions in the start menu for all users..."
DisableStoreSearchSuggestionsForAllUsers
Set-StoreSearchSuggestionsDisabledForAllUsers
Write-Host ""
return
}
@@ -159,7 +159,7 @@ function Invoke-FeatureUndo {
'DisableStoreSearchSuggestions' {
if ($script:Params.ContainsKey('Sysprep')) {
Write-Host "> Re-enabling Microsoft Store search suggestions in the start menu for all users..."
EnableStoreSearchSuggestionsForAllUsers
Set-StoreSearchSuggestionsEnabledForAllUsers
Write-Host ""
return
}
@@ -302,8 +302,8 @@ function Invoke-UndoFeatures {
.DESCRIPTION
Sequenced in four phases:
1. Registry backup
2. System restore point
1. Registry backup (skipped when SkipRegistryBackup is present)
2. System restore point (skipped when CreateRestorePoint is absent)
3. Apply phase - applies all selected features via Invoke-ApplyFeatures
4. Undo phase - undoes selected features via Invoke-UndoFeatures
@@ -349,14 +349,14 @@ function Invoke-AllChanges {
# ---- Calculate total progress steps ----
$totalSteps = $applyIds.Count + $undoIds.Count
if ($needsBackup) { $totalSteps++ }
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) { $totalSteps++ }
if ($script:Params.ContainsKey("CreateRestorePoint")) { $totalSteps++ }
$step = 0
# ================================================================
# Phase 1: Registry backup
# ================================================================
if ($needsBackup) {
if ($needsBackup -and -not $script:Params.ContainsKey('SkipRegistryBackup')) {
if ($script:CancelRequested) { return }
$step++
if ($script:ApplyProgressCallback) {
@@ -65,17 +65,103 @@ function Restore-RegistryKeySnapshot {
throw "Unsupported root-level registry path in backup: $($Snapshot.Path)"
}
Test-RegistryKeySnapshotCanBeRestored -Snapshot $Snapshot
Restore-RegistryKeySnapshotAtPath -Snapshot $Snapshot -RootKey $rootKey -SubKeyPath $subKeyPath
}
<#
.SYNOPSIS
Validates registry values and subkey paths in a snapshot before live registry state is changed.
.PARAMETER Snapshot
The registry key snapshot to validate before it is restored.
#>
function Test-RegistryKeySnapshotCanBeRestored {
param(
[Parameter(Mandatory)]
$Snapshot
)
if (-not [bool]$Snapshot.Exists) { return }
$childNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach ($valueSnapshot in @($Snapshot.Values)) {
if ([bool]$valueSnapshot.Exists) {
$valueKind = Convert-RegistryValueKindFromBackup -KindName $valueSnapshot.Kind
$null = Convert-RegistryValueDataFromBackup -Kind $valueKind -Data $valueSnapshot.Data
}
}
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
$childName = Get-DirectRegistrySnapshotChildName -ParentPath $Snapshot.Path -ChildPath $subKeySnapshot.Path
if ([string]::IsNullOrWhiteSpace($childName) -or -not $childNames.Add($childName)) {
throw "Backup contains duplicate or unsupported registry child path: $($subKeySnapshot.Path)"
}
Test-RegistryKeySnapshotCanBeRestored -Snapshot $subKeySnapshot
}
}
<#
.SYNOPSIS
Returns a snapshot child's name only when it is directly below its parent.
.PARAMETER ParentPath
The registry path of the expected parent snapshot.
.PARAMETER ChildPath
The registry path of the child snapshot to validate.
#>
function Get-DirectRegistrySnapshotChildName {
param(
[Parameter(Mandatory)]
[string]$ParentPath,
[Parameter(Mandatory)]
[string]$ChildPath
)
$parentParts = Split-RegistryPath -path $ParentPath
$childParts = Split-RegistryPath -path $ChildPath
if (-not $parentParts -or -not $childParts -or
-not $parentParts.Hive.Equals($childParts.Hive, [System.StringComparison]::OrdinalIgnoreCase) -or
[string]::IsNullOrWhiteSpace($parentParts.SubKey) -or
[string]::IsNullOrWhiteSpace($childParts.SubKey)) {
throw "Unsupported registry child path in backup: $ChildPath"
}
$childName = Split-Path -Path $childParts.SubKey -Leaf
$expectedSubKey = "$($parentParts.SubKey)\$childName"
if ([string]::IsNullOrWhiteSpace($childName) -or
-not $childParts.SubKey.Equals($expectedSubKey, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Registry child path '$ChildPath' is not directly below parent '$ParentPath'."
}
return $childName
}
<#
.SYNOPSIS
Restores a snapshot to a specific path below an already resolved registry root.
.DESCRIPTION
Writes only values and descendants represented by the backup. Existing keys are
retained so their security descriptors and unrelated data are not destroyed.
#>
function Restore-RegistryKeySnapshotAtPath {
param(
[Parameter(Mandatory)]
$Snapshot,
[Parameter(Mandatory)]
$RootKey,
[Parameter(Mandatory)]
[string]$SubKeyPath
)
if (-not $Snapshot.Exists) {
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
Remove-RegistrySubKeyTreeIfExists -RootKey $RootKey -SubKeyPath $SubKeyPath
return
}
$forceFullTree = @($Snapshot.SubKeys).Count -gt 0
if ($forceFullTree) {
Remove-RegistrySubKeyTreeIfExists -RootKey $rootKey -SubKeyPath $subKeyPath
}
$key = $rootKey.CreateSubKey($subKeyPath)
$key = $RootKey.CreateSubKey($SubKeyPath)
if ($null -eq $key) {
throw "Unable to create or open registry key '$($Snapshot.Path)'"
}
@@ -90,8 +176,11 @@ function Restore-RegistryKeySnapshot {
}
foreach ($subKeySnapshot in @($Snapshot.SubKeys)) {
Restore-RegistryKeySnapshot -Snapshot $subKeySnapshot
$childName = Get-DirectRegistrySnapshotChildName -ParentPath $Snapshot.Path -ChildPath $subKeySnapshot.Path
Restore-RegistryKeySnapshotAtPath -Snapshot $subKeySnapshot -RootKey $RootKey -SubKeyPath "$SubKeyPath\$childName"
}
}
<#
@@ -219,6 +219,11 @@ function Get-DeploymentSettings {
$deploySettings += @{ Name = 'CreateRestorePoint'; Value = [bool]$restorePointCheckBox.IsChecked }
}
$registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox) {
$deploySettings += @{ Name = 'SkipRegistryBackup'; Value = -not [bool]$registryBackupCheckBox.IsChecked }
}
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
if ($restartExplorerCheckBox) {
$deploySettings += @{ Name = 'RestartExplorer'; Value = [bool]$restartExplorerCheckBox.IsChecked }
@@ -272,6 +277,7 @@ function Get-DeploymentCategoryDetailString {
$options = @()
if ($lookup.ContainsKey('CreateRestorePoint') -and [bool]$lookup['CreateRestorePoint']) { $options += 'Restore Point' }
if (-not ($lookup.ContainsKey('SkipRegistryBackup') -and [bool]$lookup['SkipRegistryBackup'])) { $options += 'Registry Backup' }
if ($lookup.ContainsKey('RestartExplorer') -and [bool]$lookup['RestartExplorer']) { $options += 'Restart Explorer' }
$lines = @()
@@ -378,6 +384,13 @@ function Set-ImportedDeploymentSettings {
$restorePointCheckBox.IsChecked = [bool]$lookup['CreateRestorePoint']
}
$registryBackupCheckBox = $Owner.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox) {
if ($lookup.ContainsKey('SkipRegistryBackup')) {
$registryBackupCheckBox.IsChecked = -not [bool]$lookup['SkipRegistryBackup']
}
}
$restartExplorerCheckBox = $Owner.FindName('RestartExplorerCheckBox')
if ($lookup.ContainsKey('RestartExplorer') -and $restartExplorerCheckBox) {
$restartExplorerCheckBox.IsChecked = [bool]$lookup['RestartExplorer']
+11
View File
@@ -702,6 +702,11 @@ function Show-MainWindow {
Add-Parameter 'CreateRestorePoint'
}
$registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox -and -not $registryBackupCheckBox.IsChecked) {
Add-Parameter 'SkipRegistryBackup'
}
switch ($userSelectionCombo.SelectedIndex) {
0 { Write-Host "Selected user mode: current user ($(Get-UserName))" }
1 {
@@ -782,6 +787,12 @@ function Show-MainWindow {
$restartExplorerCheckBox.IsEnabled = $false
}
$registryBackupCheckBox = $window.FindName('RegistryBackupCheckBox')
if ($registryBackupCheckBox -and $script:Params.ContainsKey('SkipRegistryBackup')) {
$registryBackupCheckBox.IsChecked = $false
$registryBackupCheckBox.IsEnabled = $false
}
if ($script:Params.ContainsKey("Sysprep")) {
$userSelectionCombo.SelectedIndex = 2
$userSelectionCombo.IsEnabled = $false
+1
View File
@@ -10,6 +10,7 @@ param (
[Alias('NoRestartExplorer')]
[switch]$SkipExplorerRestart,
[switch]$CreateRestorePoint,
[switch]$SkipRegistryBackup,
[switch]$RunDefaults,
[switch]$RunDefaultsLite,
[switch]$RunSavedSettings,
@@ -81,6 +81,11 @@ function Import-ConfigToParams {
$importedItems++
}
if ($deploymentLookup.ContainsKey('SkipRegistryBackup') -and [bool]$deploymentLookup['SkipRegistryBackup']) {
Add-Parameter 'SkipRegistryBackup'
$importedItems++
}
if ($deploymentLookup.ContainsKey('RestartExplorer') -and -not [bool]$deploymentLookup['RestartExplorer']) {
Add-Parameter 'SkipExplorerRestart'
$importedItems++
+33
View File
@@ -142,3 +142,36 @@ Describe 'Restore-RegistryKeySnapshot - validation' {
Should -Throw $ExpectedError
}
}
Describe 'Invoke-RegistryDeleteValueOperation' {
It 'deletes the default value and always closes an opened registry key' {
$calls = [System.Collections.Generic.List[string]]::new()
$key = [PSCustomObject]@{}
$key | Add-Member -MemberType ScriptMethod -Name DeleteValue -Value { param($Name, $ThrowOnMissing) $calls.Add("delete:${Name}:$ThrowOnMissing") }
$key | Add-Member -MemberType ScriptMethod -Name Close -Value { $calls.Add('close') }
Invoke-RegistryDeleteValueOperation -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\Software\Test'; ValueName = $null }) -KeyInfo ([PSCustomObject]@{ Key = $key })
$calls | Should -Be @('delete::False', 'close')
}
}
Describe 'Invoke-RegistrySetValueOperation' {
It 'throws for an unavailable set-value key before attempting conversion' {
Mock Convert-RegOperationToValueKind { throw 'conversion should not run' }
{ Invoke-RegistrySetValueOperation -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\Software\Test' }) -KeyInfo ([PSCustomObject]@{ Key = $null }) } |
Should -Throw "Unable to open or create registry key*"
Should -Invoke Convert-RegOperationToValueKind -Times 0 -Exactly
}
}
Describe 'Write-RegistryOperationAccessDeniedWarning' {
It 'formats the default registry value in access-denied warnings' {
Mock Write-Warning {}
Write-RegistryOperationAccessDeniedWarning -Operation ([PSCustomObject]@{ OperationType = 'DeleteValue'; KeyPath = 'HKCU\Software\Test'; ValueName = $null }) -ExceptionMessage 'denied'
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match "value '\(Default\)'" -and $Message -match 'denied' }
}
}
+105
View File
@@ -28,4 +28,109 @@ Describe 'Get-RebootFeatureLabels' {
$result | Should -Contain 'Undo feature'
$result | Should -Not -Contain 'No reboot'
}
It 'uses the regular label for a forward-only selection' {
$script:Params = @{ ApplyFeature = $true }
$script:UndoParams = @{}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Apply feature'
}
It 'falls back to the regular label when an undo selection has no undo label' {
$script:Params = @{}
$script:UndoParams = @{ ApplyFeature = $true }
$script:Features.ApplyFeature.UndoLabel = $null
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Apply feature'
}
It 'falls back to the regular label when the feature has no UndoLabel property' {
$script:Params = @{}
$script:UndoParams = @{ MissingUndoLabelFeature = $true }
$script:Features.MissingUndoLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Feature without undo label'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 1
$result | Should -Contain 'Feature without undo label'
}
It 'returns no labels when there are no selected parameters' {
$script:Params = @{}
$script:UndoParams = @{}
@(Get-RebootFeatureLabels).Count | Should -Be 0
}
It 'keeps one label for each distinct reboot feature when their labels match' {
$script:Params = @{
FirstMatchingLabelFeature = $true
SecondMatchingLabelFeature = $true
}
$script:UndoParams = @{}
$script:Features.FirstMatchingLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Shared label'
UndoLabel = 'Undo first shared label'
}
$script:Features.SecondMatchingLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = 'Shared label'
UndoLabel = 'Undo second shared label'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 2
@($result | Where-Object { $_ -eq 'Shared label' }).Count | Should -Be 2
}
It 'accepts truthy reboot flags' {
$script:Params = @{
StringRebootFeature = $true
NumericRebootFeature = $true
}
$script:UndoParams = @{}
$script:Features.StringRebootFeature = [PSCustomObject]@{
RequiresReboot = 'true'
Label = 'String reboot'
UndoLabel = 'Undo string reboot'
}
$script:Features.NumericRebootFeature = [PSCustomObject]@{
RequiresReboot = 1
Label = 'Numeric reboot'
UndoLabel = 'Undo numeric reboot'
}
$result = @(Get-RebootFeatureLabels)
$result | Should -HaveCount 2
$result | Should -Contain 'String reboot'
$result | Should -Contain 'Numeric reboot'
}
It 'excludes unknown, non-reboot, and blank-label selections' {
$script:Params = @{
UnknownFeature = $true
NoRebootFeature = $true
BlankLabelFeature = $true
}
$script:UndoParams = @{}
$script:Features.BlankLabelFeature = [PSCustomObject]@{
RequiresReboot = $true
Label = ' '
UndoLabel = $null
}
@(Get-RebootFeatureLabels).Count | Should -Be 0
}
}
+99
View File
@@ -0,0 +1,99 @@
BeforeAll {
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
function New-WingetTestJob { Microsoft.PowerShell.Core\Start-Job -ScriptBlock {} }
. (Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Get-WingetInstalledApps.ps1')
}
Describe 'Get-WingetInstalledApps' {
BeforeEach {
$script:WingetInstalled = $true
$script:WingetTestJob = $null
Mock Remove-Job {}
}
AfterEach {
if ($null -ne $script:WingetTestJob) {
Microsoft.PowerShell.Core\Remove-Job -Job $script:WingetTestJob -Force -ErrorAction SilentlyContinue
}
}
It 'returns null without starting a job when winget is unavailable' {
$script:WingetInstalled = $false
Mock Start-Job { throw 'Winget should not be started.' }
Get-WingetInstalledApps | Should -BeNullOrEmpty
Should -Invoke Start-Job -Times 0 -Exactly
}
It 'delegates to the non-blocking runner when requested' {
Mock Invoke-NonBlocking { @([PSCustomObject]@{ Name = 'App'; Id = 'Contoso.App' }) }
$result = @(Get-WingetInstalledApps -NonBlocking)
$result | Should -HaveCount 1
$result[0].Id | Should -Be 'Contoso.App'
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly
}
It 'returns an empty collection when winget output has no table separator' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job { @('Name Id', 'No parseable table') }
@(Get-WingetInstalledApps) | Should -BeNullOrEmpty
Should -Invoke Remove-Job -Times 1 -Exactly -ParameterFilter { -not $Force }
}
It 'parses valid rows and skips malformed rows' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job {
@(
'Name Id Version'
'-----------------------------------------------------------------------'
'Contoso App Contoso.App 1.0'
'malformed-row'
'Fabrikam Tools Fabrikam.Tools 2.0'
)
}
$result = @(Get-WingetInstalledApps)
$result | Should -HaveCount 2
$result.Id | Should -Be @('Contoso.App', 'Fabrikam.Tools')
}
It 'parses localized headers and long Unicode display names' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $script:WingetTestJob }
Mock Receive-Job {
@(
'Naam Id Versie'
'-----------------------------------------------------------------------'
'Contoso hulpmiddel voor gegevens Contoso.DataTools 2026.07'
'Fabrikam Café Fabrikam.Cafe 1.0'
)
}
$result = @(Get-WingetInstalledApps)
$result | Should -HaveCount 2
$result[0].Name | Should -Be 'Contoso hulpmiddel voor gegevens'
$result.Id | Should -Be @('Contoso.DataTools', 'Fabrikam.Cafe')
}
It 'returns null and force-removes the job when winget times out' {
$script:WingetTestJob = New-WingetTestJob
Mock Start-Job { $script:WingetTestJob }
Mock Wait-Job { $null }
Get-WingetInstalledApps | Should -BeNullOrEmpty
Should -Invoke Remove-Job -Times 1 -Exactly -ParameterFilter { $Force }
}
}
+8
View File
@@ -3,6 +3,7 @@ BeforeAll {
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Add-Parameter.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Import-ConfigToParams.ps1')
$script:ConfigFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading\ExportedConfig.WithSettings.json'
$script:SkipRegistryBackupFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading\ExportedConfig.SkipRegistryBackup.json'
}
Describe 'Import-ConfigToParams' {
@@ -31,8 +32,15 @@ Describe 'Import-ConfigToParams' {
$script:Params[$featureId] | Should -BeTrue
}
$script:Params['CreateRestorePoint'] | Should -BeTrue
$script:Params.ContainsKey('SkipRegistryBackup') | Should -BeFalse
$script:Params['SkipExplorerRestart'] | Should -BeTrue
$script:Params.ContainsKey('User') | Should -BeFalse
$script:Params.ContainsKey('AppRemovalTarget') | Should -BeFalse
}
It 'imports SkipRegistryBackup when deployment settings request it' {
Import-ConfigToParams -ConfigPath $script:SkipRegistryBackupFixturePath -CurrentBuild 22631 | Out-Null
$script:Params['SkipRegistryBackup'] | Should -BeTrue
}
}
+53 -7
View File
@@ -5,7 +5,7 @@ BeforeAll {
function Enable-TelemetryScheduledTasks {}
function Generate-AppsList { @() }
function Get-FriendlyTargetUserName { 'current user' }
function EnableStoreSearchSuggestionsForAllUsers {}
function Set-StoreSearchSuggestionsEnabledForAllUsers {}
function Set-StoreSearchSuggestionsEnabled { param($StoreAppsDatabase) }
function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' }
function Get-UserName { 'Alice' }
@@ -16,7 +16,7 @@ BeforeAll {
function Get-StartMenuBinPathForUser { param($UserName) 'start.bin' }
function Replace-StartMenu { param($startMenuBinFile, $startMenuTemplate) }
function Replace-StartMenuForAllUsers { param($startMenuTemplate) }
function DisableStoreSearchSuggestionsForAllUsers {}
function Set-StoreSearchSuggestionsDisabledForAllUsers {}
function Set-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) }
. (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-Changes.ps1')
@@ -70,11 +70,11 @@ Describe 'Invoke-FeatureApply' {
Mock Get-UserName { 'Alice' }
Mock Replace-StartMenu {}
Mock Replace-StartMenuForAllUsers {}
Mock DisableStoreSearchSuggestionsForAllUsers {}
Mock Set-StoreSearchSuggestionsDisabledForAllUsers {}
Mock Set-StoreSearchSuggestionsDisabled {}
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
Mock Get-Process { @() }
Mock Stop-Process {}
Mock Stop-Process { param($InputObject) }
Mock Write-Host {}
}
@@ -132,6 +132,15 @@ Describe 'Invoke-FeatureApply' {
Should -Invoke Stop-Process -Times 0 -Exactly
}
It 'stops widget processes before removing widget packages' {
$widget = [PSCustomObject]@{ Name = 'WidgetService' }
Mock Get-Process { $widget }
Invoke-FeatureApply -FeatureId 'DisableWidgets'
Should -Invoke Stop-Process -Times 1 -Exactly
}
It 'stops widget processes without a confirmation prompt' {
Mock Get-Process { [PSCustomObject]@{ Name = 'Widgets' } }
@@ -173,7 +182,15 @@ Describe 'Invoke-FeatureApply' {
$script:Params = @{ Sysprep = $true }
Invoke-FeatureApply -FeatureId 'DisableStoreSearchSuggestions'
Should -Invoke DisableStoreSearchSuggestionsForAllUsers -Times 1 -Exactly
Should -Invoke Set-StoreSearchSuggestionsDisabledForAllUsers -Times 1 -Exactly
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 0 -Exactly
}
It 'does not update Store search suggestions when the current user database cannot be resolved' {
Mock Get-StoreAppsDatabasePathForUser { $null }
Invoke-FeatureApply -FeatureId 'DisableStoreSearchSuggestions'
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 0 -Exactly
}
}
@@ -235,6 +252,15 @@ Describe 'Invoke-UndoFeatures' {
Should -Invoke Import-RegistryFile -Times 0 -Exactly
Should -Invoke Invoke-FeatureUndo -Times 2 -Exactly
}
It 'stops before undoing when cancellation is requested' {
$script:CancelRequested = $true
Invoke-UndoFeatures -FeatureIds @('RegistryUndo') -StartStep 1 -TotalSteps 1
Should -Invoke Import-RegistryFile -Times 0 -Exactly
Should -Invoke Invoke-FeatureUndo -Times 0 -Exactly
}
}
Describe 'Invoke-FeatureUndo' {
@@ -246,7 +272,7 @@ Describe 'Invoke-FeatureUndo' {
DisableTelemetry = [PSCustomObject]@{}
DisableStoreSearchSuggestions = [PSCustomObject]@{}
}
Mock EnableStoreSearchSuggestionsForAllUsers {}
Mock Set-StoreSearchSuggestionsEnabledForAllUsers {}
Mock Set-StoreSearchSuggestionsEnabled {}
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
Mock Get-UserName { 'Alice' }
@@ -261,7 +287,7 @@ Describe 'Invoke-FeatureUndo' {
) {
$script:Params = $Params
Invoke-FeatureUndo -FeatureId 'DisableStoreSearchSuggestions'
Should -Invoke EnableStoreSearchSuggestionsForAllUsers -Times $AllUsers -Exactly
Should -Invoke Set-StoreSearchSuggestionsEnabledForAllUsers -Times $AllUsers -Exactly
Should -Invoke Set-StoreSearchSuggestionsEnabled -Times $CurrentUser -Exactly -ParameterFilter { $StoreAppsDatabase -eq 'store.db' }
}
@@ -323,6 +349,16 @@ Describe 'Invoke-AllChanges' {
Should -Invoke Invoke-SystemRestorePoint -Times 0 -Exactly
}
It 'does not create a registry backup when explicitly skipped' {
$script:Params['SkipRegistryBackup'] = $true
Invoke-AllChanges
Should -Invoke New-RegistrySettingsBackup -Times 0 -Exactly
Should -Invoke Invoke-ApplyFeatures -Times 1 -Exactly
Should -Invoke Invoke-UndoFeatures -Times 1 -Exactly
}
It 'does not run when cancellation was already requested' {
$script:CancelRequested = $true
Invoke-AllChanges
@@ -363,4 +399,14 @@ Describe 'Invoke-AllChanges' {
Invoke-AllChanges
$script:order | Should -Be @('restore-point', 'apply')
}
It 'reports registry import failures after all requested work completes' {
$script:Params = @{ CustomApply = $true }
$script:UndoParams = @{}
Mock Invoke-ApplyFeatures { $script:RegistryImportFailures = 2 }
Invoke-AllChanges
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -match '2 registry import change' }
}
}
+56
View File
@@ -0,0 +1,56 @@
BeforeAll {
function Invoke-DoEvents {}
. (Join-Path $PSScriptRoot '..\Scripts\Threading\Invoke-NonBlocking.ps1')
}
Describe 'Invoke-NonBlocking' {
BeforeEach { $script:GuiWindow = $null }
It 'runs directly in CLI mode without a timeout and preserves a scalar result' {
Invoke-NonBlocking -ScriptBlock { param($Value) "result-$Value" } -ArgumentList 'one' | Should -Be 'result-one'
}
It 'runs directly in CLI mode without a timeout and preserves multiple results' {
$result = @(Invoke-NonBlocking -ScriptBlock { 1; 2; 3 })
$result | Should -Be @(1, 2, 3)
}
It 'stops a timed CLI operation and reports a timeout' {
{ Invoke-NonBlocking -ScriptBlock { Start-Sleep -Seconds 3 } -TimeoutSeconds 1 } |
Should -Throw 'Operation timed out after 1 seconds'
}
It 'stops a timed GUI operation and reports a timeout' {
$script:GuiWindow = [PSCustomObject]@{}
Mock Invoke-DoEvents {}
{ Invoke-NonBlocking -ScriptBlock { Start-Sleep -Seconds 3 } -TimeoutSeconds 1 } |
Should -Throw 'Operation timed out after 1 seconds'
}
It 'uses a runspace and pumps UI events when a GUI window is present' {
$script:GuiWindow = [PSCustomObject]@{}
Mock Invoke-DoEvents {}
$result = Invoke-NonBlocking -ScriptBlock { 'done' }
$result | Should -Be 'done'
}
It 'surfaces non-terminating errors from a runspace' {
$script:GuiWindow = [PSCustomObject]@{}
Mock Invoke-DoEvents {}
Mock Write-Error {}
Invoke-NonBlocking -ScriptBlock { Write-Error 'runspace failure' }
Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { $ErrorRecord.Exception.Message -match 'runspace failure' }
}
It 'remains usable after a timed operation is stopped' {
{ Invoke-NonBlocking -ScriptBlock { Start-Sleep -Seconds 3 } -TimeoutSeconds 1 } |
Should -Throw 'Operation timed out after 1 seconds'
Invoke-NonBlocking -ScriptBlock { 'next operation' } | Should -Be 'next operation'
}
}
+133
View File
@@ -0,0 +1,133 @@
BeforeAll {
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList, $TimeoutSeconds) }
function Show-MessageBox { param($Message, $Title, $Button, $Icon) }
function Enable-ComputerRestore { param($Drive) }
function Get-ComputerRestorePoint {}
function Checkpoint-Computer { param($Description, $RestorePointType) }
. (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-SystemRestorePoint.ps1')
}
Describe 'Invoke-SystemRestorePoint' {
BeforeEach {
$script:GuiWindow = $null
$script:CancelRequested = $false
$script:Silent = $false
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 1 } }
Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $true; Message = 'System restore point created successfully' } }
Mock Read-Host { 'y' }
Mock Show-MessageBox { 'Yes' }
Mock Write-Host {}
}
It 'creates a restore point when System Restore is already enabled' {
Invoke-SystemRestorePoint
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $TimeoutSeconds -eq 90 }
Should -Invoke Read-Host -Times 0 -Exactly
$script:CancelRequested | Should -BeFalse
}
It 'is loaded by the main entry point' {
$entryPoint = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\Win11Debloat.ps1') -Raw
$expectedImport = [regex]::Escape('Scripts/Features/Invoke-SystemRestorePoint.ps1')
$entryPoint | Should -Match $expectedImport
Get-Command Invoke-SystemRestorePoint | Should -Not -BeNullOrEmpty
}
It 'enables disabled System Restore before creating the point in silent mode' {
$script:Silent = $true
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 0 } }
$script:nonBlockingCalls = 0
Mock Invoke-NonBlocking {
$script:nonBlockingCalls++
if ($script:nonBlockingCalls -eq 1) { return $null }
return [PSCustomObject]@{ Success = $true; Message = 'System restore point created successfully' }
}
Invoke-SystemRestorePoint
Should -Invoke Invoke-NonBlocking -Times 2 -Exactly
Should -Invoke Read-Host -Times 0 -Exactly
}
It 'enables System Restore only for the Windows system drive' {
$script:Silent = $true
$script:nonBlockingCalls = 0
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 0 } }
Mock Invoke-NonBlocking {
param($ScriptBlock)
$script:nonBlockingCalls++
if ($script:nonBlockingCalls -eq 1) {
$script:enableRestoreBlock = $ScriptBlock
return $null
}
return [PSCustomObject]@{ Success = $true; Message = 'System restore point created successfully' }
}
Mock Enable-ComputerRestore {}
Invoke-SystemRestorePoint
& $script:enableRestoreBlock
Should -Invoke Enable-ComputerRestore -Times 1 -Exactly -ParameterFilter { $Drive -eq $env:SystemDrive }
}
It 'creates only a MODIFY_SETTINGS restore point with the project description' {
$script:restorePointBlock = $null
Mock Invoke-NonBlocking {
param($ScriptBlock)
$script:restorePointBlock = $ScriptBlock
return [PSCustomObject]@{ Success = $true; Message = 'System restore point created successfully' }
}
Mock Get-ComputerRestorePoint { @() }
Mock Checkpoint-Computer {}
Invoke-SystemRestorePoint
& $script:restorePointBlock
Should -Invoke Checkpoint-Computer -Times 1 -Exactly -ParameterFilter {
$Description -eq 'Restore point created by Win11Debloat' -and $RestorePointType -eq 'MODIFY_SETTINGS'
}
}
It 'cancels when an interactive user declines enabling disabled System Restore' {
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 0 } }
Mock Read-Host { 'n' }
Invoke-SystemRestorePoint
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
$script:CancelRequested | Should -BeTrue
}
It 'cancels when restore point creation fails and the CLI user declines continuation' {
Mock Invoke-NonBlocking { [PSCustomObject]@{ Success = $false; Message = 'creation failed' } }
Mock Read-Host { 'n' }
Invoke-SystemRestorePoint
$script:CancelRequested | Should -BeTrue
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -eq 'creation failed' }
}
It 'offers the CLI continuation choice when enabling System Restore fails' {
Mock Get-ItemProperty { [PSCustomObject]@{ RPSessionInterval = 0 } }
Mock Invoke-NonBlocking { throw 'enable failed' }
Mock Read-Host { 'n' }
Invoke-SystemRestorePoint
$script:CancelRequested | Should -BeTrue
}
It 'continues after a GUI failure only when the dialog confirms it' {
$script:GuiWindow = [PSCustomObject]@{}
Mock Invoke-NonBlocking { $null }
Mock Show-MessageBox { 'No' }
Invoke-SystemRestorePoint
$script:CancelRequested | Should -BeTrue
Should -Invoke Show-MessageBox -Times 1 -Exactly
}
}
@@ -0,0 +1,61 @@
BeforeAll {
function Get-RegistryBackupCapturePlans {}
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Features\Registry-BackupValidation.ps1')
}
Describe 'Test-RegistryBackupMatchesSelectedFeatures' {
BeforeEach {
$script:Features = @{
ApplyFeature = [PSCustomObject]@{ RegistryKey = 'apply.reg'; RegistryUndoKey = 'undo.reg' }
}
$script:CapturePlanCalls = [System.Collections.Generic.List[bool]]::new()
Mock Get-RegistryBackupCapturePlans {
param($SelectedRegistryFeatures, $UndoRegistryFeatures, $UseSysprepRegFiles)
$script:CapturePlanCalls.Add([bool]$UseSysprepRegFiles)
@([PSCustomObject]@{
Path = 'HKEY_CURRENT_USER\Software\Example'
IncludeSubKeys = $false
CaptureAllValues = $false
ValueNames = @('Enabled')
})
}
}
It 'derives an allow list from selected features and accepts a matching snapshot' {
$snapshot = [PSCustomObject]@{
Path = 'HKEY_CURRENT_USER\Software\Example'
Values = @([PSCustomObject]@{ Name = 'Enabled'; Exists = $true; Kind = 'DWord'; Data = 1 })
SubKeys = @()
}
$errors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @('ApplyFeature') -SelectedUndoFeatureIds @() -Target 'CurrentUser' -RegistryKeys @($snapshot))
$errors | Should -BeNullOrEmpty
$script:CapturePlanCalls | Should -Be @($false)
}
It 'uses Sysprep registry files when restoring a user-profile backup' {
$errors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @('ApplyFeature') -SelectedUndoFeatureIds @() -Target 'User:Alice' -RegistryKeys @())
$errors | Should -BeNullOrEmpty
$script:CapturePlanCalls | Should -Be @($true)
}
It 'reports unknown selected features without deriving capture plans' {
$errors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @('UnknownFeature') -SelectedUndoFeatureIds @() -Target 'CurrentUser' -RegistryKeys @())
$errors | Should -Contain "Selected feature 'UnknownFeature' was not found in the current feature catalog."
$script:CapturePlanCalls | Should -BeNullOrEmpty
}
It 'rejects registry snapshots when no feature definitions are loaded' {
$script:Features = @{}
$snapshot = [PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Example'; Values = @(); SubKeys = @() }
$errors = @(Test-RegistryBackupMatchesSelectedFeatures -SelectedFeatureIds @('ApplyFeature') -SelectedUndoFeatureIds @() -Target 'CurrentUser' -RegistryKeys @($snapshot))
$errors | Should -Contain 'Unable to validate registry backup allowlist because feature definitions are not loaded.'
}
}
+31
View File
@@ -118,6 +118,14 @@ Describe 'Convert-RegistryKeyToSnapshot' {
$snapshot.SubKeys[0].Values[0].Data | Should -Be 'data'
$child.Closed | Should -BeTrue
}
It 'fails instead of silently omitting an unreadable child key' {
$root = New-FakeRegistryKey -Children @{ Locked = $null }
{
Convert-RegistryKeyToSnapshot -RegistryKey $root -FullPath $root.Name -CaptureAllValues:$true -IncludeSubKeys:$true
} | Should -Throw '*Unable to read registry subkey*The backup was not created*'
}
}
Describe 'Get-RegistryKeySnapshot' {
@@ -203,3 +211,26 @@ Describe 'Restore-RegistryKeySnapshot - mutation flow' {
$key.Closed | Should -BeTrue
}
}
Describe 'Test-RegistryKeySnapshotCanBeRestored' {
It 'rejects invalid value data before the live registry is touched' {
$snapshot = [PSCustomObject]@{
Path = 'HKEY_CURRENT_USER\Software\Example'; Exists = $true
Values = @([PSCustomObject]@{ Name = 'TooLarge'; Exists = $true; Kind = 'DWord'; Data = 4294967296 })
SubKeys = @()
}
{ Test-RegistryKeySnapshotCanBeRestored -Snapshot $snapshot } |
Should -Throw '*Value was either too large or too small*'
}
It 'rejects a descendant that is not directly below its declared parent' {
$snapshot = [PSCustomObject]@{
Path = 'HKEY_CURRENT_USER\Software\Example'; Exists = $true; Values = @()
SubKeys = @([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Example\Nested\Child'; Exists = $true; Values = @(); SubKeys = @() })
}
{ Test-RegistryKeySnapshotCanBeRestored -Snapshot $snapshot } |
Should -Throw "*is not directly below parent*"
}
}
+108 -15
View File
@@ -2,7 +2,6 @@ BeforeAll {
function Get-UserDirectory { param($userName, $fileName, $exitIfPathNotFound) }
. (Join-Path $PSScriptRoot '..\Scripts\Features\Replace-StartMenu.ps1')
. (Join-Path $PSScriptRoot '..\Scripts\Features\Telemetry-ScheduledTasks.ps1')
}
Describe 'Get-StartMenuUserNameFromPath' {
@@ -62,6 +61,38 @@ Describe 'Get-StartMenuUserNameFromPath' {
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'current'
}
It 'preserves a recoverable copy of the current layout when restoring fails' {
$script:Params = @{}
$startMenuFile = Join-Path $TestDrive 'start2.bin'
$backupFile = Join-Path $TestDrive 'Win11Debloat-StartBackup-20260101_120000.bak'
Set-Content -LiteralPath $startMenuFile -Value 'current'
Set-Content -LiteralPath $backupFile -Value 'backup'
Mock Copy-Item { throw 'disk full' }
$result = Restore-StartMenuFromBackup -StartMenuBinFile $startMenuFile -BackupFilePath $backupFile
$result.Result | Should -BeFalse
$result.Message | Should -Match 'disk full'
$restoreCopies = @(Get-ChildItem -LiteralPath $TestDrive -Filter 'Win11Debloat-StartRestore-*.bak')
$restoreCopies.Count | Should -Be 1
Get-Content -LiteralPath $restoreCopies[0].FullName -Raw | Should -Match 'current'
}
It 'leaves the original layout in place when it cannot be moved for restore' {
$script:Params = @{}
$startMenuFile = Join-Path $TestDrive 'start2.bin'
$backupFile = Join-Path $TestDrive 'Win11Debloat-StartBackup-20260101_120000.bak'
Set-Content -LiteralPath $startMenuFile -Value 'current'
Set-Content -LiteralPath $backupFile -Value 'backup'
Mock Move-Item { throw 'locked' }
$result = Restore-StartMenuFromBackup -StartMenuBinFile $startMenuFile -BackupFilePath $backupFile
$result.Result | Should -BeFalse
$result.Message | Should -Match 'locked'
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'current'
}
It 'delegates current-user restore to the common backup operation' {
$script:Params = @{}
Mock Restore-StartMenuFromBackup { [PSCustomObject]@{ Result = $true } }
@@ -88,21 +119,83 @@ Describe 'Get-StartMenuUserNameFromPath' {
Test-Path -LiteralPath $defaultBin | Should -BeFalse
}
}
Describe 'Replace-StartMenu' {
BeforeEach {
$script:Params = @{}
Mock Write-Host {}
}
Describe 'Get-TelemetryScheduledTasks' {
It 'returns the expected telemetry task catalog' {
$tasks = @(Get-TelemetryScheduledTasks)
It 'backs up and replaces an existing start-menu file' {
$startMenuFile = Join-Path $TestDrive 'start2.bin'
$templateFile = Join-Path $TestDrive 'template.bin'
Set-Content -LiteralPath $startMenuFile -Value 'current layout'
Set-Content -LiteralPath $templateFile -Value 'replacement layout'
$tasks.Count | Should -Be 8
@($tasks | ForEach-Object { "$($_.Path)|$($_.Name)" }) | Should -Be @(
'\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser'
'\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser Exp'
'\Microsoft\Windows\Application Experience\|ProgramDataUpdater'
'\Microsoft\Windows\Application Experience\|StartupAppTask'
'\Microsoft\Windows\Customer Experience Improvement Program\|Consolidator'
'\Microsoft\Windows\Customer Experience Improvement Program\|UsbCeip'
'\Microsoft\Windows\DiskDiagnostic\|Microsoft-Windows-DiskDiagnosticDataCollector'
'\Microsoft\Windows\Autochk\|Proxy'
)
Replace-StartMenu -startMenuBinFile $startMenuFile -startMenuTemplate $templateFile
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'replacement layout'
@(Get-ChildItem -LiteralPath $TestDrive -Filter 'Win11Debloat-StartBackup-*.bak').Count | Should -Be 1
}
It 'creates and replaces a missing start-menu file without a backup' {
$testDirectory = Join-Path $TestDrive 'missing-layout'
New-Item -ItemType Directory -Path $testDirectory | Out-Null
$startMenuFile = Join-Path $testDirectory 'start2.bin'
$templateFile = Join-Path $testDirectory 'template.bin'
$backupCountBefore = @(Get-ChildItem -LiteralPath $testDirectory -Filter 'Win11Debloat-StartBackup-*.bak').Count
Set-Content -LiteralPath $templateFile -Value 'replacement layout'
Replace-StartMenu -startMenuBinFile $startMenuFile -startMenuTemplate $templateFile
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'replacement layout'
@(Get-ChildItem -LiteralPath $testDirectory -Filter 'Win11Debloat-StartBackup-*.bak').Count | Should -Be $backupCountBefore
}
It 'does not change a start-menu file in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
$testDirectory = Join-Path $TestDrive 'whatif-layout'
New-Item -ItemType Directory -Path $testDirectory | Out-Null
$startMenuFile = Join-Path $testDirectory 'start2.bin'
$templateFile = Join-Path $testDirectory 'template.bin'
$backupCountBefore = @(Get-ChildItem -LiteralPath $testDirectory -Filter 'Win11Debloat-StartBackup-*.bak').Count
Set-Content -LiteralPath $startMenuFile -Value 'current layout'
Set-Content -LiteralPath $templateFile -Value 'replacement layout'
Replace-StartMenu -startMenuBinFile $startMenuFile -startMenuTemplate $templateFile
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'current layout'
@(Get-ChildItem -LiteralPath $testDirectory -Filter 'Win11Debloat-StartBackup-*.bak').Count | Should -Be $backupCountBefore
}
}
Describe 'Replace-StartMenuForAllUsers guard paths' {
BeforeEach {
$script:Params = @{}
$script:AssetsPath = $TestDrive
Mock Get-UserDirectory { Join-Path $TestDrive 'Users' }
Mock Get-ChildItem { @() }
Mock Replace-StartMenu {}
Mock New-Item {}
Mock Write-Host {}
}
It 'does not touch profiles when the template is missing' {
Replace-StartMenuForAllUsers -startMenuTemplate (Join-Path $TestDrive 'missing.bin')
Should -Invoke Get-UserDirectory -Times 0 -Exactly
Should -Invoke Replace-StartMenu -Times 0 -Exactly
}
It 'does not create or replace the Default profile in WhatIf mode' {
$template = Join-Path $TestDrive 'template.bin'
Set-Content -LiteralPath $template -Value 'template'
$script:Params = @{ WhatIf = $true }
Mock Test-Path { param($Path) $Path -eq $template }
Replace-StartMenuForAllUsers -startMenuTemplate $template
Should -Invoke New-Item -Times 0 -Exactly
Should -Invoke Replace-StartMenu -Times 0 -Exactly
}
}
+40
View File
@@ -11,6 +11,46 @@ BeforeAll {
function Get-RebootFeatureLabels { @() }
}
Describe 'Resolve-UserProfilePath helper fallbacks' {
BeforeEach {
$script:ResolvedUserSidCache = @{}
$script:MachineDomainJoinStateKnown = $null
$script:MachineIsDomainJoined = $false
$script:MachineNetBiosDomain = ''
}
It 'uses strict normalized equality on workgroup machines' {
Mock Test-MachineIsDomainJoined { $false }
(Test-UserNameMatch -UserNameA ' Alice ' -UserNameB 'alice') | Should -BeTrue
(Test-UserNameMatch -UserNameA 'alice' -UserNameB 'alice.CONTOSO') | Should -BeFalse
}
It 'matches domain-qualified names to domain-suffixed profile folders' {
Mock Test-MachineIsDomainJoined { $true }
Mock Get-ProfileFolderDomainSuffix { 'CONTOSO' }
Test-UserNameMatchesProfileLeaf -UserName 'CONTOSO\alice' -ProfileLeaf 'alice.CONTOSO' | Should -BeTrue
}
It 'returns null for a SID cache miss and does not cache blank SIDs' {
Get-CachedResolvedUserSid -Candidates @('missing') | Should -BeNullOrEmpty
Set-ResolvedUserSidCache -Candidates @('Alice') -Sid ''
$script:ResolvedUserSidCache.Count | Should -Be 0
}
It 'falls back to an exact profile directory when SID resolution is unavailable' {
Mock Resolve-UserSid { $null }
Mock Test-Path { $true }
$context = Resolve-UserProfileContext -UserName 'Alice'
$context.UserName | Should -Be 'Alice'
$context.UserSid | Should -BeNullOrEmpty
$context.ProfilePath | Should -Match 'Alice$'
}
}
Describe 'Test-UserProfileExists' {
BeforeEach {
Mock Resolve-UserProfileContext {
+219
View File
@@ -0,0 +1,219 @@
BeforeAll {
function Get-UserDirectory { param($userName, $fileName) }
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
function takeown { param([Parameter(ValueFromRemainingArguments)]$Arguments) }
function icacls { param([Parameter(ValueFromRemainingArguments)]$Arguments) }
function New-TestStoreDatabaseAcl {
param([object[]]$Access = @())
$acl = [PSCustomObject]@{
Access = $Access
AddedRules = [System.Collections.Generic.List[object]]::new()
RemovedRules = [System.Collections.Generic.List[object]]::new()
}
$acl | Add-Member -MemberType ScriptMethod -Name SetAccessRule -Value {
param($Rule)
$this.AddedRules.Add($Rule)
}
$acl | Add-Member -MemberType ScriptMethod -Name RemoveAccessRuleSpecific -Value {
param($Rule)
$this.RemovedRules.Add($Rule)
return $true
}
return $acl
}
. (Join-Path $PSScriptRoot '..\Scripts\Features\Set-StoreSearchSuggestions.ps1')
}
Describe 'Store-search suggestion all-user operations' {
BeforeEach {
$script:Params = @{}
Mock Get-UserDirectory { 'C:\Users\*\AppData\Local\Packages' }
Mock Get-ChildItem {
@(
[PSCustomObject]@{ FullName = 'C:\Users\Alice\AppData\Local\Packages' }
[PSCustomObject]@{ FullName = 'C:\Users\Bob\AppData\Local\Packages' }
)
}
Mock Get-StoreAppsDatabasePathForUser { 'C:\Users\Default\AppData\Local\Packages\Microsoft.WindowsStore_8wekyb3d8bbwe\LocalState\store.db' }
Mock Set-StoreSearchSuggestionsDisabled {}
Mock Set-StoreSearchSuggestionsEnabled {}
Mock Write-Warning {}
}
It 'disables suggestions for every discovered and Default profile' {
Set-StoreSearchSuggestionsDisabledForAllUsers
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 3 -Exactly
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 1 -Exactly -ParameterFilter { $StoreAppsDatabase -match 'Users\\Default\\' }
}
It 'enables suggestions for every discovered and Default profile' {
Set-StoreSearchSuggestionsEnabledForAllUsers
Should -Invoke Set-StoreSearchSuggestionsEnabled -Times 3 -Exactly
Should -Invoke Set-StoreSearchSuggestionsEnabled -Times 1 -Exactly -ParameterFilter { $StoreAppsDatabase -match 'Users\\Default\\' }
}
It 'does not add a Default profile disable operation when its Store database path cannot be resolved' {
Mock Get-StoreAppsDatabasePathForUser { $null }
Set-StoreSearchSuggestionsDisabledForAllUsers
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 2 -Exactly
}
It 'does not add a Default profile enable operation when its Store database path cannot be resolved' {
Mock Get-StoreAppsDatabasePathForUser { $null }
Set-StoreSearchSuggestionsEnabledForAllUsers
Should -Invoke Set-StoreSearchSuggestionsEnabled -Times 2 -Exactly
}
}
Describe 'Set-StoreSearchSuggestionsDisabled' {
BeforeEach {
$script:Params = @{ WhatIf = $true }
Mock Test-Path { throw 'WhatIf should return before filesystem access.' }
Mock Get-Acl { throw 'WhatIf should return before ACL access.' }
Mock Set-Acl { throw 'WhatIf should return before ACL access.' }
Mock Write-Host {}
}
It 'does not touch the filesystem in WhatIf mode' {
Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke Test-Path -Times 0 -Exactly
Should -Invoke Get-Acl -Times 0 -Exactly
Should -Invoke Set-Acl -Times 0 -Exactly
}
It 'creates a missing database and parent directory before applying the deny rule' {
$script:Params = @{}
$acl = New-TestStoreDatabaseAcl
Mock Test-Path { $false }
Mock New-Item {}
Mock Get-Acl { $acl }
Mock Set-Acl {}
Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke New-Item -Times 2 -Exactly
Should -Invoke New-Item -Times 1 -Exactly -ParameterFilter { $ItemType -eq 'Directory' }
Should -Invoke New-Item -Times 1 -Exactly -ParameterFilter { $ItemType -eq 'File' }
Should -Invoke New-Item -Times 1 -Exactly -ParameterFilter { $Path -eq 'C:\Users\Alice\AppData\Local\Packages' -and $ItemType -eq 'Directory' -and $Force }
Should -Invoke New-Item -Times 1 -Exactly -ParameterFilter { $Path -eq 'C:\Users\Alice\AppData\Local\Packages\store.db' -and $ItemType -eq 'File' -and $Force }
Should -Invoke Set-Acl -Times 1 -Exactly -ParameterFilter { $Path -eq 'C:\Users\Alice\AppData\Local\Packages\store.db' -and $AclObject -eq $acl }
$acl.AddedRules | Should -HaveCount 1
}
It 'updates the ACL without creating anything when the database already exists' {
$script:Params = @{}
$acl = New-TestStoreDatabaseAcl
Mock Test-Path { $true }
Mock New-Item { throw 'Existing database must not be recreated.' }
Mock Get-Acl { $acl }
Mock Set-Acl {}
Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke New-Item -Times 0 -Exactly
Should -Invoke Get-Acl -Times 1 -Exactly
Should -Invoke Set-Acl -Times 1 -Exactly
$acl.AddedRules | Should -HaveCount 1
}
It 'surfaces ACL failures instead of reporting the database as disabled' {
$script:Params = @{}
Mock Test-Path { $true }
Mock Get-Acl { throw 'access denied' }
Mock Set-Acl { throw 'ACL must not be written after a read failure.' }
{
Set-StoreSearchSuggestionsDisabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
} | Should -Throw '*access denied*'
Should -Invoke Set-Acl -Times 0 -Exactly
}
}
Describe 'Set-StoreSearchSuggestionsEnabled' {
BeforeEach {
$script:Params = @{}
Mock Test-Path { $false }
Mock Get-Acl { throw 'A missing database should return before ACL access.' }
Mock Remove-Item { throw 'A missing database should not be removed.' }
Mock Write-Host {}
}
It 'does nothing when the Store database does not exist' {
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke Get-Acl -Times 0 -Exactly
Should -Invoke Remove-Item -Times 0 -Exactly
}
It 'does not touch an existing database in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Mock Test-Path { throw 'WhatIf should return before filesystem access.' }
Mock takeown { throw 'WhatIf should not take ownership.' }
Mock icacls { throw 'WhatIf should not change ACLs.' }
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke Test-Path -Times 0 -Exactly
Should -Invoke takeown -Times 0 -Exactly
Should -Invoke icacls -Times 0 -Exactly
}
It 'normalizes the ACL and removes an existing database' {
$acl = New-TestStoreDatabaseAcl
Mock Test-Path { $true }
Mock takeown {}
Mock icacls {}
Mock Get-Acl { $acl }
Mock Set-Acl {}
Mock Remove-Item {}
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke takeown -Times 1 -Exactly
Should -Invoke icacls -Times 1 -Exactly
Should -Invoke Get-Acl -Times 1 -Exactly
Should -Invoke Set-Acl -Times 1 -Exactly
Should -Invoke Remove-Item -Times 1 -Exactly -ParameterFilter { $Path -eq 'C:\Users\Alice\AppData\Local\Packages\store.db' -and $Force -and $ErrorAction -eq 'Stop' }
}
It 'continues removing an existing database when ACL normalization fails' {
Mock Test-Path { $true }
Mock takeown {}
Mock icacls {}
Mock Get-Acl { throw 'access denied' }
Mock Set-Acl { throw 'Set-Acl must not run after Get-Acl fails.' }
Mock Remove-Item {}
Mock Write-Warning {}
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
Should -Invoke Write-Warning -Times 1 -Exactly
Should -Invoke Set-Acl -Times 0 -Exactly
Should -Invoke Remove-Item -Times 1 -Exactly
}
It 'throws a contextual error when the database cannot be removed' {
$acl = New-TestStoreDatabaseAcl
Mock Test-Path { $true }
Mock takeown {}
Mock icacls {}
Mock Get-Acl { $acl }
Mock Set-Acl {}
Mock Remove-Item { throw 'database is locked' }
{
Set-StoreSearchSuggestionsEnabled -StoreAppsDatabase 'C:\Users\Alice\AppData\Local\Packages\store.db'
} | Should -Throw '*Failed to remove*database is locked*'
}
}
+182
View File
@@ -0,0 +1,182 @@
BeforeAll {
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
. (Join-Path $PSScriptRoot '..\Scripts\Features\Telemetry-ScheduledTasks.ps1')
}
Describe 'Get-TelemetryScheduledTasks' {
It 'returns the expected telemetry task catalog' {
$tasks = @(Get-TelemetryScheduledTasks)
$tasks.Count | Should -Be 8
@($tasks | ForEach-Object { "$($_.Path)|$($_.Name)" }) | Should -Be @(
'\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser'
'\Microsoft\Windows\Application Experience\|Microsoft Compatibility Appraiser Exp'
'\Microsoft\Windows\Application Experience\|ProgramDataUpdater'
'\Microsoft\Windows\Application Experience\|StartupAppTask'
'\Microsoft\Windows\Customer Experience Improvement Program\|Consolidator'
'\Microsoft\Windows\Customer Experience Improvement Program\|UsbCeip'
'\Microsoft\Windows\DiskDiagnostic\|Microsoft-Windows-DiskDiagnosticDataCollector'
'\Microsoft\Windows\Autochk\|Proxy'
)
}
}
Describe 'Disable-TelemetryScheduledTasks' {
BeforeEach {
$script:Params = @{}
$script:CancelRequested = $false
Mock Get-TelemetryScheduledTasks {
@(
@{ Path = '\Microsoft\Windows\Test\'; Name = 'First' }
@{ Path = '\Microsoft\Windows\Test\'; Name = 'Second' }
)
}
Mock Invoke-NonBlocking { @{ Success = $true; Status = 'Disabled' } }
Mock Write-Host {}
}
It 'dispatches every task to the non-blocking scheduler' {
Disable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 2 -Exactly
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter {
$ArgumentList[0] -eq '\Microsoft\Windows\Test\' -and $ArgumentList[1] -eq 'First'
}
}
It 'does not schedule work after cancellation' {
$script:CancelRequested = $true
Disable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'does not schedule task changes in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Disable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'disables an enabled task inside the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:taskBlock = $ScriptBlock
$script:taskArguments = $ArgumentList
}
Mock Import-Module {}
Mock Get-ScheduledTask { [PSCustomObject]@{ State = 'Ready' } }
Mock Disable-ScheduledTask {}
Disable-TelemetryScheduledTasks
$result = & $script:taskBlock @script:taskArguments
$result.Status | Should -Be 'Disabled'
Should -Invoke Disable-ScheduledTask -Times 1 -Exactly -ParameterFilter { $TaskPath -eq '\Microsoft\Windows\Test\' -and $TaskName -eq 'Second' }
}
It 'reports an error returned by the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:taskBlock = $ScriptBlock
$script:taskArguments = $ArgumentList
}
Mock Import-Module {}
Mock Get-ScheduledTask { [PSCustomObject]@{ State = 'Ready' } }
Mock Disable-ScheduledTask { throw 'access denied' }
Disable-TelemetryScheduledTasks
$result = & $script:taskBlock @script:taskArguments
$result.Status | Should -Be 'Error'
$result.Error | Should -Match 'access denied'
}
It 'reports <Status> task results' -ForEach @(
@{ Status = 'Disabled'; Expected = 'Disabled Scheduled Task' }
@{ Status = 'AlreadyDisabled'; Expected = 'already disabled' }
@{ Status = 'NotFound'; Expected = 'not found' }
@{ Status = 'Error'; Expected = 'Failed to disable Scheduled Task' }
) {
Mock Get-TelemetryScheduledTasks { @(@{ Path = '\Microsoft\Windows\Test\'; Name = 'Telemetry' }) }
Mock Invoke-NonBlocking { @{ Success = $Status -ne 'Error'; Status = $Status; Error = 'denied' } }
Disable-TelemetryScheduledTasks
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like "*$Expected*" }
}
}
Describe 'Enable-TelemetryScheduledTasks' {
BeforeEach {
$script:Params = @{}
$script:CancelRequested = $false
Mock Get-TelemetryScheduledTasks {
@(
@{ Path = '\Microsoft\Windows\Test\'; Name = 'First' }
@{ Path = '\Microsoft\Windows\Test\'; Name = 'Second' }
)
}
Mock Invoke-NonBlocking { @{ Success = $true; Status = 'Enabled' } }
Mock Write-Host {}
}
It 'dispatches every task to the non-blocking scheduler' {
Enable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 2 -Exactly
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter {
$ArgumentList[0] -eq '\Microsoft\Windows\Test\' -and $ArgumentList[1] -eq 'First'
}
}
It 'does not schedule work after cancellation' {
$script:CancelRequested = $true
Enable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'does not schedule task changes in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Enable-TelemetryScheduledTasks
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'enables a disabled task inside the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:taskBlock = $ScriptBlock
$script:taskArguments = $ArgumentList
}
Mock Import-Module {}
Mock Get-ScheduledTask { [PSCustomObject]@{ State = 'Disabled' } }
Mock Enable-ScheduledTask {}
Enable-TelemetryScheduledTasks
$result = & $script:taskBlock @script:taskArguments
$result.Status | Should -Be 'Enabled'
Should -Invoke Enable-ScheduledTask -Times 1 -Exactly -ParameterFilter { $TaskPath -eq '\Microsoft\Windows\Test\' -and $TaskName -eq 'Second' }
}
It 'reports <Status> task results' -ForEach @(
@{ Status = 'Enabled'; Expected = 'Enabled Scheduled Task' }
@{ Status = 'AlreadyEnabled'; Expected = 'already enabled' }
@{ Status = 'NotFound'; Expected = 'not found' }
@{ Status = 'Error'; Expected = 'Failed to enable Scheduled Task' }
) {
Mock Get-TelemetryScheduledTasks { @(@{ Path = '\Microsoft\Windows\Test\'; Name = 'Telemetry' }) }
Mock Invoke-NonBlocking { @{ Success = $Status -ne 'Error'; Status = $Status; Error = 'denied' } }
Enable-TelemetryScheduledTasks
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like "*$Expected*" }
}
}
@@ -0,0 +1,6 @@
{
"Version": "1.0",
"Deployment": [
{ "Name": "SkipRegistryBackup", "Value": true }
]
}
@@ -4,6 +4,7 @@
{ "Name": "UserSelectionIndex", "Value": 0 },
{ "Name": "AppRemovalScopeIndex", "Value": 0 },
{ "Name": "CreateRestorePoint", "Value": true },
{ "Name": "SkipRegistryBackup", "Value": false },
{ "Name": "RestartExplorer", "Value": false }
],
"Tweaks": [
+93
View File
@@ -0,0 +1,93 @@
Describe 'Test suite safety convention' {
It 'does not directly execute high-impact Windows mutation commands' {
$protectedCommands = @(
'Add-AppxPackage'
'Add-AppxProvisionedPackage'
'Add-WindowsCapability'
'Checkpoint-Computer'
'Clear-ItemProperty'
'Disable-ComputerRestore'
'Disable-ScheduledTask'
'Disable-WindowsOptionalFeature'
'Enable-ComputerRestore'
'Enable-ScheduledTask'
'Enable-WindowsOptionalFeature'
'Install-Package'
'Invoke-Expression'
'iex'
'New-ItemProperty'
'New-PSDrive'
'Register-ScheduledTask'
'Remove-AppxPackage'
'Remove-AppxProvisionedPackage'
'Remove-ItemProperty'
'Remove-WindowsCapability'
'Restart-Service'
'Set-Acl'
'Set-ExecutionPolicy'
'Set-ItemProperty'
'Set-MpPreference'
'Set-Service'
'Set-WinUserLanguageList'
'Start-Service'
'Start-Process'
'Stop-Service'
'Stop-Process'
'Uninstall-Package'
'Unregister-ScheduledTask'
'bcdedit'
'bcdedit.exe'
'dism'
'dism.exe'
'fsutil'
'fsutil.exe'
'takeown'
'icacls'
'reg'
'reg.exe'
'schtasks'
'schtasks.exe'
'sc.exe'
'wevtutil'
'wevtutil.exe'
)
$violations = foreach ($testFile in Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.Tests.ps1' -File) {
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($testFile.FullName, [ref]$tokens, [ref]$parseErrors)
$parseErrors | Should -BeNullOrEmpty
foreach ($command in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)) {
$commandName = $command.GetCommandName()
if ($commandName -and $protectedCommands -contains $commandName) {
"$($testFile.Name): $commandName"
}
}
}
$violations | Should -BeNullOrEmpty
}
It 'does not use literal system paths with direct filesystem mutation commands' {
$fileMutationCommands = @('Clear-Content', 'Copy-Item', 'Move-Item', 'New-Item', 'Remove-Item', 'Set-Content', 'Set-Item')
$violations = foreach ($testFile in Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.Tests.ps1' -File) {
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($testFile.FullName, [ref]$tokens, [ref]$parseErrors)
foreach ($command in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)) {
if ($fileMutationCommands -notcontains $command.GetCommandName()) { continue }
foreach ($element in $command.CommandElements) {
if ($element -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) { continue }
if ($element.Value -match '^(?:[A-Za-z]:\\|\\\\|(?:HKCU|HKLM|HKU|HKCR):)') {
"$($testFile.Name): $($command.GetCommandName()) $($element.Value)"
}
}
}
}
$violations | Should -BeNullOrEmpty
}
}
+11
View File
@@ -0,0 +1,11 @@
Describe 'Wait-ForKeyPress' {
It 'exits cleanly without prompting when Silent is enabled' {
$scriptPath = Join-Path $PSScriptRoot '..\Scripts\CLI\Wait-ForKeyPress.ps1'
$command = "function Stop-Transcript {}; `$global:Silent = `$true; . '$scriptPath'; Wait-ForKeyPress"
$encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command))
& powershell.exe -NoProfile -EncodedCommand $encodedCommand
$LASTEXITCODE | Should -Be 0
}
}
+156
View File
@@ -0,0 +1,156 @@
BeforeAll {
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
function Enable-WindowsOptionalFeature {
param([switch]$Online, $FeatureName, [switch]$All, [switch]$NoRestart)
[void]$global:OptionalFeatureCalls.Add([PSCustomObject]@{ Action = 'Enable'; Online = $Online; FeatureName = $FeatureName; All = $All; NoRestart = $NoRestart })
}
function Disable-WindowsOptionalFeature {
param([switch]$Online, $FeatureName, [switch]$NoRestart)
[void]$global:OptionalFeatureCalls.Add([PSCustomObject]@{ Action = 'Disable'; Online = $Online; FeatureName = $FeatureName; All = $false; NoRestart = $NoRestart })
}
function Get-WindowsOptionalFeature {
param([switch]$Online, $FeatureName, $ErrorAction)
[void]$global:OptionalFeatureQueryCalls.Add([PSCustomObject]@{ Online = $Online; FeatureName = $FeatureName; ErrorAction = $ErrorAction })
if ($global:OptionalFeatureQueryThrows) {
throw 'feature service unavailable'
}
return [PSCustomObject]@{ State = $global:OptionalFeatureQueryState }
}
. (Join-Path $PSScriptRoot '..\Scripts\Features\Windows-OptionalFeatures.ps1')
}
Describe 'Enable-WindowsFeature' {
BeforeEach {
$script:Params = @{}
Mock Invoke-NonBlocking { @() }
Mock Write-Host {}
}
It 'schedules the requested feature with the non-blocking runner' {
Enable-WindowsFeature -FeatureName 'Feature.One'
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $ArgumentList -eq 'Feature.One' }
}
It 'calls Enable-WindowsOptionalFeature with the expected arguments' {
$global:OptionalFeatureCalls = [System.Collections.Generic.List[object]]::new()
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:optionalFeatureBlock = $ScriptBlock
$script:optionalFeatureArguments = $ArgumentList
}
Enable-WindowsFeature -FeatureName 'Feature.One'
& $script:optionalFeatureBlock $script:optionalFeatureArguments
$global:OptionalFeatureCalls | Should -HaveCount 1
$global:OptionalFeatureCalls[0].Action | Should -Be 'Enable'
$global:OptionalFeatureCalls[0].Online | Should -BeTrue
$global:OptionalFeatureCalls[0].FeatureName | Should -Be 'Feature.One'
$global:OptionalFeatureCalls[0].All | Should -BeTrue
$global:OptionalFeatureCalls[0].NoRestart | Should -BeTrue
}
It 'does not schedule changes in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Enable-WindowsFeature -FeatureName 'Feature.One'
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'surfaces an optional-feature enable failure from the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:optionalFeatureBlock = $ScriptBlock
$script:optionalFeatureArguments = $ArgumentList
}
Mock Enable-WindowsOptionalFeature { throw 'feature servicing failed' }
Enable-WindowsFeature -FeatureName 'Feature.One'
{ & $script:optionalFeatureBlock $script:optionalFeatureArguments } | Should -Throw 'feature servicing failed'
}
}
Describe 'Disable-WindowsFeature' {
BeforeEach {
$script:Params = @{}
Mock Invoke-NonBlocking { @() }
Mock Write-Host {}
}
It 'schedules the requested feature with the non-blocking runner' {
Disable-WindowsFeature -FeatureName 'Feature.One'
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $ArgumentList -eq 'Feature.One' }
}
It 'calls Disable-WindowsOptionalFeature with the expected arguments' {
$global:OptionalFeatureCalls = [System.Collections.Generic.List[object]]::new()
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:optionalFeatureBlock = $ScriptBlock
$script:optionalFeatureArguments = $ArgumentList
}
Disable-WindowsFeature -FeatureName 'Feature.One'
& $script:optionalFeatureBlock $script:optionalFeatureArguments
$global:OptionalFeatureCalls | Should -HaveCount 1
$global:OptionalFeatureCalls[0].Action | Should -Be 'Disable'
$global:OptionalFeatureCalls[0].Online | Should -BeTrue
$global:OptionalFeatureCalls[0].FeatureName | Should -Be 'Feature.One'
$global:OptionalFeatureCalls[0].All | Should -BeFalse
$global:OptionalFeatureCalls[0].NoRestart | Should -BeTrue
}
It 'does not schedule changes in WhatIf mode' {
$script:Params = @{ WhatIf = $true }
Disable-WindowsFeature -FeatureName 'Feature.One'
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
}
It 'surfaces an optional-feature disable failure from the scheduled script block' {
Mock Invoke-NonBlocking {
param($ScriptBlock, $ArgumentList)
$script:optionalFeatureBlock = $ScriptBlock
$script:optionalFeatureArguments = $ArgumentList
}
Mock Disable-WindowsOptionalFeature { throw 'feature servicing failed' }
Disable-WindowsFeature -FeatureName 'Feature.One'
{ & $script:optionalFeatureBlock $script:optionalFeatureArguments } | Should -Throw 'feature servicing failed'
}
}
Describe 'Test-WindowsOptionalFeatureEnabled' {
BeforeEach {
$global:OptionalFeatureQueryCalls = [System.Collections.Generic.List[object]]::new()
$global:OptionalFeatureQueryThrows = $false
$global:OptionalFeatureQueryState = 'Disabled'
}
It 'returns true for an enabled optional feature' {
$global:OptionalFeatureQueryState = 'Enabled'
Test-WindowsOptionalFeatureEnabled -FeatureName 'Feature.One' | Should -BeTrue
$global:OptionalFeatureQueryCalls | Should -HaveCount 1
$global:OptionalFeatureQueryCalls[0].Online | Should -BeTrue
$global:OptionalFeatureQueryCalls[0].FeatureName | Should -Be 'Feature.One'
$global:OptionalFeatureQueryCalls[0].ErrorAction | Should -Be 'Stop'
}
It 'returns false for a non-enabled optional feature' {
Test-WindowsOptionalFeatureEnabled -FeatureName 'Feature.One' | Should -BeFalse
}
It 'returns false when the optional-feature query fails' {
$global:OptionalFeatureQueryThrows = $true
Test-WindowsOptionalFeatureEnabled -FeatureName 'Feature.One' | Should -BeFalse
}
}
+62
View File
@@ -0,0 +1,62 @@
Describe 'XAML UI contracts' {
BeforeAll {
$script:SchemaPath = Join-Path $PSScriptRoot '..\Schemas'
$script:GuiPath = Join-Path $PSScriptRoot '..\Scripts\GUI'
$script:XamlFiles = @(Get-ChildItem -LiteralPath $script:SchemaPath -Filter '*.xaml' -File)
}
It 'keeps every schema well-formed XML' {
foreach ($xamlFile in $script:XamlFiles) {
{ [xml](Get-Content -LiteralPath $xamlFile.FullName -Raw) } |
Should -Not -Throw "XAML schema '$($xamlFile.Name)' must remain well-formed."
}
}
It 'keeps every literal FindName reference backed by an XAML control' {
$xamlNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($xamlFile in $script:XamlFiles) {
$content = Get-Content -LiteralPath $xamlFile.FullName -Raw
foreach ($match in [regex]::Matches($content, '(?:x:Name|\bName)\s*=\s*"([^"]+)"')) {
[void]$xamlNames.Add($match.Groups[1].Value)
}
}
$references = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($guiFile in Get-ChildItem -LiteralPath $script:GuiPath -Filter '*.ps1' -File -Recurse) {
$content = Get-Content -LiteralPath $guiFile.FullName -Raw
foreach ($match in [regex]::Matches($content, "\.FindName\(\s*'([^']+)'\s*\)")) {
[void]$references.Add($match.Groups[1].Value)
}
}
$missing = @($references | Where-Object { -not $xamlNames.Contains($_) } | Sort-Object)
$missing | Should -BeNullOrEmpty
}
It 'keeps the main window navigation and deployment controls available' {
$mainWindow = Get-Content -LiteralPath (Join-Path $script:SchemaPath 'MainWindow.xaml') -Raw
$requiredNames = @(
'MainTabControl'
'HomeTab'
'DeploymentSettingsTab'
'DeploymentApplyBtn'
'UserSelectionCombo'
'AppSelectionPanel'
'AppSelectionStatus'
)
foreach ($name in $requiredNames) {
$mainWindow | Should -Match ('(?:x:Name|\bName)\s*=\s*"{0}"' -f [regex]::Escape($name))
}
}
It 'keeps destructive modal actions accessible by automation name' {
$applyWindow = Get-Content -LiteralPath (Join-Path $script:SchemaPath 'ApplyChangesWindow.xaml') -Raw
$appWindow = Get-Content -LiteralPath (Join-Path $script:SchemaPath 'AppSelectionWindow.xaml') -Raw
$applyWindow | Should -Match 'AutomationProperties.Name="Cancel"'
$applyWindow | Should -Match 'AutomationProperties.Name="Close"'
$appWindow | Should -Match 'AutomationProperties.Name="Confirm"'
$appWindow | Should -Match 'AutomationProperties.Name="Cancel"'
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ param (
[Alias('NoRestartExplorer')]
[switch]$SkipExplorerRestart,
[switch]$CreateRestorePoint,
[switch]$SkipRegistryBackup,
[switch]$RunDefaults,
[switch]$RunDefaultsLite,
[switch]$RunSavedSettings,
@@ -186,7 +187,7 @@ $script:RestoreBackupWindowSchema = Join-Path $schemasPath 'RestoreBackupWindow.
$script:LoadAppsDetailsScriptPath = Join-Path (Join-Path $scriptsPath 'FileIO') 'Import-AppDetailsFromJson.ps1'
$script:TestAppInWingetListScriptPath = Join-Path (Join-Path $scriptsPath 'AppRemoval') 'Test-AppInWingetList.ps1'
$script:ControlParams = 'WhatIf', 'Confirm', 'Verbose', 'Debug', 'LogPath', 'Silent', 'Sysprep', 'User', 'SkipExplorerRestart', 'RunDefaults', 'RunDefaultsLite', 'RunSavedSettings', 'Config', 'CLI', 'AppRemovalTarget'
$script:ControlParams = 'WhatIf', 'Confirm', 'Verbose', 'Debug', 'LogPath', 'Silent', 'Sysprep', 'User', 'SkipExplorerRestart', 'SkipRegistryBackup', 'RunDefaults', 'RunDefaultsLite', 'RunSavedSettings', 'Config', 'CLI', 'AppRemovalTarget'
# Script-level variables for GUI elements
$script:GuiWindow = $null
@@ -329,7 +330,7 @@ if (-not $script:WingetInstalled -and -not $Silent) {
# Features functions
. "$PSScriptRoot/Scripts/Features/Get-CurrentTweakState.ps1"
. "$PSScriptRoot/Scripts/Features/Invoke-Changes.ps1"
. "$PSScriptRoot/Scripts/Features/Ensure-SystemRestorePoint.ps1"
. "$PSScriptRoot/Scripts/Features/Invoke-SystemRestorePoint.ps1"
. "$PSScriptRoot/Scripts/Features/Backup-RegistryFeatureSelection.ps1"
. "$PSScriptRoot/Scripts/Features/Backup-RegistrySnapshotCapture.ps1"
. "$PSScriptRoot/Scripts/Features/Backup-RegistryState.ps1"