mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-08-23 08:02:07 +00:00
Add comprehensive test suite, fix minor issues, rename function and file names to match approved verbs (#708)
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
BeforeAll {
|
||||
function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject) }
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RegFileOperations.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Apply-RegistryRegFile.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryApplyState.ps1')
|
||||
}
|
||||
|
||||
Describe 'Convert-RegOperationToValueKind' {
|
||||
It 'converts <Case> to a registry-compatible value' -ForEach @(
|
||||
@{ Case = 'an unsigned DWord'; ValueName = $null; ValueType = 'DWord'; ValueData = [uint32]::MaxValue; ExpectedName = ''; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::DWord; ExpectedValue = -1 }
|
||||
@{ Case = 'a string value'; ValueName = 'Name'; ValueType = 'String'; ValueData = 42; ExpectedName = 'Name'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::String; ExpectedValue = '42' }
|
||||
@{ Case = 'a binary value'; ValueName = 'Bytes'; ValueType = 'Binary'; ValueData = @(1, 255); ExpectedName = 'Bytes'; ExpectedKind = [Microsoft.Win32.RegistryValueKind]::Binary; ExpectedValue = [byte[]](1, 255) }
|
||||
) {
|
||||
$result = Convert-RegOperationToValueKind -Operation ([PSCustomObject]@{
|
||||
KeyPath = 'HK'; ValueName = $ValueName; ValueType = $ValueType; ValueData = $ValueData
|
||||
})
|
||||
|
||||
$result.Name | Should -Be $ExpectedName
|
||||
$result.Kind | Should -Be $ExpectedKind
|
||||
$result.Value | Should -Be $ExpectedValue
|
||||
}
|
||||
|
||||
It 'throws for unsupported value types' {
|
||||
{ Convert-RegOperationToValueKind -Operation ([PSCustomObject]@{ KeyPath = 'HKCU\X'; ValueType = 'Hex9'; ValueData = 1 }) } |
|
||||
Should -Throw "Unsupported value type 'Hex9' while applying reg operation for 'HKCU\X'"
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RegistryKeyForOperation' {
|
||||
It 'rejects <Case>' -ForEach @(
|
||||
@{ Case = 'an unsupported path format'; RegistryPath = 'HKCU\Software\Example'; ExpectedError = 'Unsupported registry path:*' }
|
||||
@{ Case = 'an unsupported registry hive'; RegistryPath = 'HKEY_UNKNOWN\Software\Example'; ExpectedError = "Unsupported registry hive 'HKEY_UNKNOWN'*" }
|
||||
) {
|
||||
{ Get-RegistryKeyForOperation -RegistryPath $RegistryPath } | Should -Throw $ExpectedError
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-RegistryOperation' {
|
||||
BeforeEach {
|
||||
Mock Get-RegistryKeyForOperation { [PSCustomObject]@{ RootKey = [Microsoft.Win32.Registry]::CurrentUser; SubKeyPath = 'Software\Example'; Key = 'key' } }
|
||||
Mock Remove-RegistrySubKeyTreeIfExists {}
|
||||
Mock Invoke-RegistryDeleteValueOperation {}
|
||||
Mock Invoke-RegistrySetValueOperation {}
|
||||
}
|
||||
|
||||
It 'dispatches <Type> to <Expected>' -ForEach @(
|
||||
@{ Type = 'DeleteKey'; Expected = 'Remove-RegistrySubKeyTreeIfExists' }
|
||||
@{ Type = 'DeleteValue'; Expected = 'Invoke-RegistryDeleteValueOperation' }
|
||||
@{ Type = 'SetValue'; Expected = 'Invoke-RegistrySetValueOperation' }
|
||||
) {
|
||||
$operation = [PSCustomObject]@{ OperationType = $Type; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Value' }
|
||||
|
||||
Invoke-RegistryOperation -Operation $operation -RegFilePath 'feature.reg'
|
||||
|
||||
Should -Invoke $Expected -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'opens keys with create=<Create> and open=<Open> for <Type>' -ForEach @(
|
||||
@{ Type = 'DeleteKey'; Create = $false; Open = $false }
|
||||
@{ Type = 'DeleteValue'; Create = $false; Open = $true }
|
||||
@{ Type = 'SetValue'; Create = $true; Open = $true }
|
||||
) {
|
||||
$operation = [PSCustomObject]@{ OperationType = $Type; KeyPath = 'HKEY_CURRENT_USER\Software\Example' }
|
||||
|
||||
Invoke-RegistryOperation -Operation $operation -RegFilePath 'feature.reg'
|
||||
|
||||
Should -Invoke Get-RegistryKeyForOperation -Times 1 -Exactly -ParameterFilter {
|
||||
[bool]$CreateIfMissing -eq $Create -and [bool]$OpenKey -eq $Open
|
||||
}
|
||||
}
|
||||
|
||||
It 'rejects unknown operation types with file context' {
|
||||
$operation = [PSCustomObject]@{ OperationType = 'Unknown'; KeyPath = 'HKEY_CURRENT_USER\Software\Example' }
|
||||
{ Invoke-RegistryOperation -Operation $operation -RegFilePath 'feature.reg' } |
|
||||
Should -Throw "Unsupported reg operation type 'Unknown' in 'feature.reg'"
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-RegistryOperationsFromRegFile' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKCU\One' }, [PSCustomObject]@{ OperationType = 'DeleteValue'; KeyPath = 'HKCU\Two' }) }
|
||||
Mock Invoke-RegistryOperation {}
|
||||
Mock Write-RegistryOperationAccessDeniedWarning {}
|
||||
Mock Write-Warning {}
|
||||
Mock Write-Host {}
|
||||
}
|
||||
|
||||
It 'honors WhatIf without dispatching operations' {
|
||||
$script:Params = @{ WhatIf = $true }
|
||||
|
||||
Invoke-RegistryOperationsFromRegFile -RegFilePath 'feature.reg'
|
||||
|
||||
Should -Invoke Invoke-RegistryOperation -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'continues after one access-denied operation and emits a summary warning' {
|
||||
$script:calls = 0
|
||||
Mock Invoke-RegistryOperation {
|
||||
$script:calls++
|
||||
if ($script:calls -eq 1) { throw [System.UnauthorizedAccessException]::new('denied') }
|
||||
}
|
||||
|
||||
{ Invoke-RegistryOperationsFromRegFile -RegFilePath 'feature.reg' } | Should -Not -Throw
|
||||
Should -Invoke Write-RegistryOperationAccessDeniedWarning -Times 1 -Exactly
|
||||
Should -Invoke Write-Warning -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'throws when every operation is blocked by access restrictions' {
|
||||
Mock Invoke-RegistryOperation { throw [System.Security.SecurityException]::new('blocked') }
|
||||
|
||||
{ Invoke-RegistryOperationsFromRegFile -RegFilePath 'feature.reg' } |
|
||||
Should -Throw "Registry fallback import could not apply any operations in 'feature.reg' because all 2 operation(s) were blocked*"
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-WithLoadedRestoreHive' {
|
||||
BeforeEach { Mock Invoke-WithTargetUserHive { param($TargetUserName) $TargetUserName } }
|
||||
|
||||
It 'maps <Target> to <ExpectedUser>' -ForEach @(
|
||||
@{ Target = 'DefaultUserProfile'; ExpectedUser = 'Default' }
|
||||
@{ Target = 'User:Alice'; ExpectedUser = 'Alice' }
|
||||
) {
|
||||
Invoke-WithLoadedRestoreHive -Target $Target -ScriptBlock {} | Should -Be $ExpectedUser
|
||||
}
|
||||
|
||||
It 'rejects <Case>' -ForEach @(
|
||||
@{ Case = 'an empty user target'; Target = 'User:'; ExpectedError = 'Invalid backup target format for user restore.' }
|
||||
@{ Case = 'a current-user target'; Target = 'CurrentUser:Alice'; ExpectedError = "Unsupported backup target 'CurrentUser:Alice'." }
|
||||
) {
|
||||
{ Invoke-WithLoadedRestoreHive -Target $Target -ScriptBlock {} } | Should -Throw $ExpectedError
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Restore-RegistryKeySnapshot - validation' {
|
||||
It 'rejects <Case> before registry mutation' -ForEach @(
|
||||
@{ Case = 'an unsupported snapshot path'; Path = 'HKCU\Software'; ExpectedError = 'Unsupported registry path in backup:*' }
|
||||
@{ Case = 'a root-level snapshot path'; Path = 'HKEY_CURRENT_USER'; ExpectedError = 'Unsupported root-level registry path in backup:*' }
|
||||
) {
|
||||
{ Restore-RegistryKeySnapshot -Snapshot ([PSCustomObject]@{ Path = $Path; Exists = $true }) } |
|
||||
Should -Throw $ExpectedError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
BeforeAll {
|
||||
function Show-MessageBox { param($Message, $Title, $Button, $Icon, $Owner) 'Yes' }
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Confirm-UnsafeAppRemoval.ps1')
|
||||
}
|
||||
|
||||
Describe 'Confirm-UnsafeAppRemoval' {
|
||||
BeforeEach {
|
||||
$global:Silent = $false
|
||||
Mock Show-MessageBox { 'Yes' }
|
||||
}
|
||||
|
||||
AfterEach { Remove-Variable -Name Silent -Scope Global -ErrorAction SilentlyContinue }
|
||||
|
||||
It 'returns true without prompting for ordinary applications' {
|
||||
Confirm-UnsafeAppRemoval -SelectedApps @('Contoso.App') | Should -BeTrue
|
||||
Should -Invoke Show-MessageBox -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'skips all prompts in silent mode' {
|
||||
$global:Silent = $true
|
||||
|
||||
Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeTrue
|
||||
Should -Invoke Show-MessageBox -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'stops when Microsoft Store removal is declined' {
|
||||
Mock Show-MessageBox { 'No' }
|
||||
|
||||
Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeFalse
|
||||
Should -Invoke Show-MessageBox -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'requires confirmation for both dangerous applications' {
|
||||
Confirm-UnsafeAppRemoval -SelectedApps @('Microsoft.WindowsStore', 'Microsoft.WindowsTerminal') | Should -BeTrue
|
||||
Should -Invoke Show-MessageBox -Times 2 -Exactly
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
BeforeAll {
|
||||
function Test-StoreSearchSuggestionsDisabledForAllUsers { $false }
|
||||
function Test-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) $false }
|
||||
function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' }
|
||||
function Get-UserName { 'Alice' }
|
||||
function Test-WindowsOptionalFeatureEnabled { param($FeatureName) $false }
|
||||
function Get-RegFileOperations { param($regFilePath) @() }
|
||||
function Split-RegistryPath { param($path) $null }
|
||||
function Get-RegistryRootKey { param($hiveName) $null }
|
||||
|
||||
function New-CurrentStateRegistryKey {
|
||||
param([hashtable]$Values = @{}, [hashtable]$Kinds = @{})
|
||||
$key = [PSCustomObject]@{ Values = $Values; Kinds = $Kinds; Closed = $false }
|
||||
$key | Add-Member ScriptMethod GetValueNames { @($this.Kinds.Keys) }
|
||||
$key | Add-Member ScriptMethod GetValueKind { param($name) $this.Kinds[$name] }
|
||||
$key | Add-Member ScriptMethod GetValue { param($name, $defaultValue, $options) $this.Values[$name] }
|
||||
$key | Add-Member ScriptMethod Close { $this.Closed = $true }
|
||||
return $key
|
||||
}
|
||||
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Get-CurrentTweakState.ps1')
|
||||
}
|
||||
|
||||
Describe 'Get-ExpectedRegistryValueKind' {
|
||||
It 'maps <ValueType> to <Expected>' -ForEach @(
|
||||
@{ ValueType = 'DWord'; Expected = [Microsoft.Win32.RegistryValueKind]::DWord }
|
||||
@{ ValueType = 'QWord'; Expected = [Microsoft.Win32.RegistryValueKind]::QWord }
|
||||
@{ ValueType = 'String'; Expected = [Microsoft.Win32.RegistryValueKind]::String }
|
||||
@{ ValueType = 'Binary'; Expected = [Microsoft.Win32.RegistryValueKind]::Binary }
|
||||
@{ ValueType = 'Hex2'; Expected = [Microsoft.Win32.RegistryValueKind]::ExpandString }
|
||||
@{ ValueType = 'Hex7'; Expected = [Microsoft.Win32.RegistryValueKind]::MultiString }
|
||||
) {
|
||||
$operation = [PSCustomObject]@{ ValueType = $ValueType }
|
||||
|
||||
Get-ExpectedRegistryValueKind -Operation $operation | Should -Be $Expected
|
||||
}
|
||||
|
||||
It 'returns null for unsupported operation types' {
|
||||
Get-ExpectedRegistryValueKind -Operation ([PSCustomObject]@{ ValueType = 'Hex11' }) | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-FeatureApplied - special features' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:Features = @{
|
||||
DisableWidgets = [PSCustomObject]@{}
|
||||
DisableStoreSearchSuggestions = [PSCustomObject]@{}
|
||||
EnableWindowsSandbox = [PSCustomObject]@{}
|
||||
EnableWindowsSubsystemForLinux = [PSCustomObject]@{}
|
||||
}
|
||||
Mock Get-AppxPackage { $null }
|
||||
Mock Test-StoreSearchSuggestionsDisabledForAllUsers { $true }
|
||||
Mock Test-StoreSearchSuggestionsDisabled { $true }
|
||||
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
|
||||
Mock Get-UserName { 'Alice' }
|
||||
Mock Test-WindowsOptionalFeatureEnabled { $true }
|
||||
}
|
||||
|
||||
It '<Case>' -ForEach @(
|
||||
@{ Case = 'treats Widgets as disabled when all related packages are absent'; PresentPackage = $null; Expected = $true; ExpectedCalls = 3 }
|
||||
@{ Case = 'treats Widgets as enabled when a related package is present'; PresentPackage = 'MicrosoftWindows.Client.WebExperience'; Expected = $false; ExpectedCalls = 2 }
|
||||
) {
|
||||
Mock Get-AppxPackage { param($Name) if ($Name -eq $PresentPackage) { [PSCustomObject]@{ Name = $Name } } }
|
||||
|
||||
Test-FeatureApplied -FeatureId 'DisableWidgets' | Should -Be $Expected
|
||||
Should -Invoke Get-AppxPackage -Times $ExpectedCalls -Exactly
|
||||
}
|
||||
|
||||
It 'uses <Case> Store detection' -ForEach @(
|
||||
@{ Case = 'all-user'; Params = @{ Sysprep = $true }; AllUsersCalls = 1; UserCalls = 0 }
|
||||
@{ Case = 'user-specific'; Params = @{}; AllUsersCalls = 0; UserCalls = 1 }
|
||||
) {
|
||||
$script:Params = $Params
|
||||
Test-FeatureApplied -FeatureId 'DisableStoreSearchSuggestions' | Should -BeTrue
|
||||
Should -Invoke Test-StoreSearchSuggestionsDisabledForAllUsers -Times $AllUsersCalls -Exactly
|
||||
Should -Invoke Test-StoreSearchSuggestionsDisabled -Times $UserCalls -Exactly -ParameterFilter { $StoreAppsDatabase -eq 'store.db' }
|
||||
}
|
||||
|
||||
It 'checks the expected optional feature for Windows Sandbox' {
|
||||
Test-FeatureApplied -FeatureId 'EnableWindowsSandbox' | Should -BeTrue
|
||||
Should -Invoke Test-WindowsOptionalFeatureEnabled -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Containers-DisposableClientVM' }
|
||||
}
|
||||
|
||||
It '<Case>' -ForEach @(
|
||||
@{ Case = 'reports WSL applied when both optional features are enabled'; DisabledFeature = $null; Expected = $true }
|
||||
@{ Case = 'reports WSL not applied when VirtualMachinePlatform is disabled'; DisabledFeature = 'VirtualMachinePlatform'; Expected = $false }
|
||||
) {
|
||||
Mock Test-WindowsOptionalFeatureEnabled { param($FeatureName) $FeatureName -ne $DisabledFeature }
|
||||
Test-FeatureApplied -FeatureId 'EnableWindowsSubsystemForLinux' | Should -Be $Expected
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-FeatureApplied - registry preconditions' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:RegfilesPath = $TestDrive
|
||||
$script:Features = @{
|
||||
NoRegistry = [PSCustomObject]@{ RegistryKey = '' }
|
||||
MissingFile = [PSCustomObject]@{ RegistryKey = 'missing.reg' }
|
||||
EmptyOperations = [PSCustomObject]@{ RegistryKey = 'empty.reg' }
|
||||
}
|
||||
}
|
||||
|
||||
It 'returns false for <Case>' -ForEach @(
|
||||
@{ Case = 'a feature without registry data'; FeatureId = 'NoRegistry'; ParserBehavior = 'None' }
|
||||
@{ Case = 'a missing registry file'; FeatureId = 'MissingFile'; ParserBehavior = 'None' }
|
||||
@{ Case = 'an empty registry operation set'; FeatureId = 'EmptyOperations'; ParserBehavior = 'Empty' }
|
||||
@{ Case = 'a registry operation parse failure'; FeatureId = 'EmptyOperations'; ParserBehavior = 'Throw' }
|
||||
) {
|
||||
if ($ParserBehavior -ne 'None') {
|
||||
'' | Set-Content -LiteralPath (Join-Path $TestDrive 'empty.reg')
|
||||
if ($ParserBehavior -eq 'Empty') { Mock Get-RegFileOperations { @() } }
|
||||
if ($ParserBehavior -eq 'Throw') { Mock Get-RegFileOperations { throw 'parse failed' } }
|
||||
}
|
||||
|
||||
Test-FeatureApplied -FeatureId $FeatureId | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-FeatureApplied - registry state comparison' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:RegfilesPath = $TestDrive
|
||||
$script:Features = @{ RegistryFeature = [PSCustomObject]@{ RegistryKey = 'feature.reg' } }
|
||||
'' | Set-Content -LiteralPath (Join-Path $TestDrive 'feature.reg')
|
||||
Mock Split-RegistryPath { [PSCustomObject]@{ Hive = 'HKEY_CURRENT_USER'; SubKey = 'Software\Example' } }
|
||||
}
|
||||
|
||||
It 'matches set values by kind and normalized unsigned data and closes the key' {
|
||||
$key = New-CurrentStateRegistryKey -Values @{ Large = -1L } -Kinds @{ Large = [Microsoft.Win32.RegistryValueKind]::QWord }
|
||||
$root = [PSCustomObject]@{ Key = $key }
|
||||
$root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) $this.Key }
|
||||
Mock Get-RegistryRootKey { $root }
|
||||
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Large'; ValueType = 'QWord'; ValueData = [uint64]::MaxValue }) }
|
||||
|
||||
Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeTrue
|
||||
$key.Closed | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'returns false for a value-kind or data mismatch' -ForEach @(
|
||||
@{ ActualKind = [Microsoft.Win32.RegistryValueKind]::String; ActualData = '1'; ExpectedType = 'DWord'; ExpectedData = 1 }
|
||||
@{ ActualKind = [Microsoft.Win32.RegistryValueKind]::DWord; ActualData = 2; ExpectedType = 'DWord'; ExpectedData = 1 }
|
||||
) {
|
||||
$key = New-CurrentStateRegistryKey -Values @{ Enabled = $ActualData } -Kinds @{ Enabled = $ActualKind }
|
||||
$root = [PSCustomObject]@{ Key = $key }
|
||||
$root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) $this.Key }
|
||||
Mock Get-RegistryRootKey { $root }
|
||||
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = 'SetValue'; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Enabled'; ValueType = $ExpectedType; ValueData = $ExpectedData }) }
|
||||
|
||||
Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeFalse
|
||||
$key.Closed | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'treats missing keys and values as successful delete operations' -ForEach @(
|
||||
@{ OperationType = 'DeleteKey'; ReturnKey = $false }
|
||||
@{ OperationType = 'DeleteValue'; ReturnKey = $true }
|
||||
) {
|
||||
$key = New-CurrentStateRegistryKey
|
||||
$root = [PSCustomObject]@{ Key = $key; ReturnKey = $ReturnKey }
|
||||
$root | Add-Member ScriptMethod OpenSubKey { param($path, $writable) if ($this.ReturnKey) { $this.Key } else { $null } }
|
||||
Mock Get-RegistryRootKey { $root }
|
||||
Mock Get-RegFileOperations { @([PSCustomObject]@{ OperationType = $OperationType; KeyPath = 'HKEY_CURRENT_USER\Software\Example'; ValueName = 'Gone' }) }
|
||||
|
||||
Test-FeatureApplied -FeatureId 'RegistryFeature' | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-CurrentGroupActiveIndex' {
|
||||
BeforeEach { Mock Test-FeatureApplied { $false } }
|
||||
|
||||
It 'returns the one-based index of the first fully applied option' {
|
||||
$group = [PSCustomObject]@{ Values = @(
|
||||
[PSCustomObject]@{ FeatureIds = @('One', 'Missing') }
|
||||
[PSCustomObject]@{ FeatureIds = @('Two', 'Three') }
|
||||
) }
|
||||
Mock Test-FeatureApplied { param($FeatureId) $FeatureId -in @('Two', 'Three') }
|
||||
|
||||
Get-CurrentGroupActiveIndex -Group $group | Should -Be 2
|
||||
}
|
||||
|
||||
It 'returns zero when no option is fully applied' {
|
||||
$group = [PSCustomObject]@{ Values = @([PSCustomObject]@{ FeatureIds = @('One') }) }
|
||||
Get-CurrentGroupActiveIndex -Group $group | Should -Be 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
BeforeAll {
|
||||
$friendlyTargetScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-FriendlyRegistryBackupTarget.ps1'
|
||||
. $friendlyTargetScriptPath
|
||||
}
|
||||
|
||||
Describe 'Get-FriendlyRegistryBackupTarget' {
|
||||
It 'formats <Case> as <Expected>' -ForEach @(
|
||||
@{ Case = 'a null target'; Target = $null; Expected = 'Unknown' }
|
||||
@{ Case = 'the default profile'; Target = 'DefaultUserProfile'; Expected = 'Default user profile' }
|
||||
@{ Case = 'the current-user marker'; Target = 'CurrentUser'; Expected = 'Current user' }
|
||||
@{ Case = 'the all-users marker'; Target = 'AllUsers'; Expected = 'All users' }
|
||||
@{ Case = 'a named current user'; Target = 'CurrentUser:Alice'; Expected = 'Current user (Alice)' }
|
||||
@{ Case = 'a named target user'; Target = 'User:Bob'; Expected = 'User (Bob)' }
|
||||
) {
|
||||
Get-FriendlyRegistryBackupTarget -Target $Target | Should -Be $Expected
|
||||
}
|
||||
|
||||
It 'keeps unrecognized target text visible to the user' {
|
||||
Get-FriendlyRegistryBackupTarget -Target 'Custom:Value' | Should -Be 'Custom:Value'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
BeforeAll {
|
||||
$rebootFeatureLabelsScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RebootFeatureLabels.ps1'
|
||||
. $rebootFeatureLabelsScriptPath
|
||||
}
|
||||
|
||||
Describe 'Get-RebootFeatureLabels' {
|
||||
BeforeEach {
|
||||
$script:Params = @{
|
||||
ApplyFeature = $true
|
||||
NoRebootFeature = $true
|
||||
}
|
||||
$script:UndoParams = @{
|
||||
UndoFeature = $true
|
||||
ApplyFeature = $true
|
||||
}
|
||||
$script:Features = @{
|
||||
ApplyFeature = [PSCustomObject]@{ RequiresReboot = $true; Label = 'Apply feature'; UndoLabel = 'Undo apply feature' }
|
||||
UndoFeature = [PSCustomObject]@{ RequiresReboot = $true; Label = 'Undoable feature'; UndoLabel = 'Undo feature' }
|
||||
NoRebootFeature = [PSCustomObject]@{ RequiresReboot = $false; Label = 'No reboot'; UndoLabel = 'Undo no reboot' }
|
||||
}
|
||||
}
|
||||
|
||||
It 'includes reboot-required selections once and uses undo labels for undo operations' {
|
||||
$result = @(Get-RebootFeatureLabels)
|
||||
|
||||
$result | Should -HaveCount 2
|
||||
$result | Should -Contain 'Undo apply feature'
|
||||
$result | Should -Contain 'Undo feature'
|
||||
$result | Should -Not -Contain 'No reboot'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
BeforeAll {
|
||||
$regFileOperationsScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RegFileOperations.ps1'
|
||||
. $regFileOperationsScriptPath
|
||||
}
|
||||
|
||||
Describe 'Convert-RegValueData' {
|
||||
It 'parses <ValueType> as an unsigned integer' -ForEach @(
|
||||
@{ ValueType = 'DWord'; ValueData = 'dword:ffffffff'; Expected = [uint32]::MaxValue }
|
||||
@{ ValueType = 'QWord'; ValueData = 'qword:ffffffffffffffff'; Expected = [uint64]::MaxValue }
|
||||
) {
|
||||
$result = Convert-RegValueData -valueData $ValueData
|
||||
|
||||
$result.OperationType | Should -Be 'SetValue'
|
||||
$result.ValueType | Should -Be $ValueType
|
||||
$result.ValueData | Should -Be $Expected
|
||||
}
|
||||
|
||||
It 'parses registry strings and unescapes quotes and backslashes' {
|
||||
$result = Convert-RegValueData -valueData '"C:\\Tools\\\"Quoted\""'
|
||||
|
||||
$result.ValueType | Should -Be 'String'
|
||||
$result.ValueData | Should -Be 'C:\Tools\"Quoted"'
|
||||
}
|
||||
|
||||
It 'parses <Case>' -ForEach @(
|
||||
@{ Case = 'binary hex data'; ValueData = 'hex:01,ff'; ExpectedType = 'Binary'; Expected = [byte[]](1, 255) }
|
||||
@{ Case = 'expandable-string hex data'; ValueData = 'hex(2):25,00,54,00,45,00,4d,00,50,00,25,00,00,00'; ExpectedType = 'Hex2'; Expected = '%TEMP%' }
|
||||
@{ Case = 'multi-string hex data'; ValueData = 'hex(7):6f,00,6e,00,65,00,00,00,74,00,77,00,6f,00,00,00,00,00'; ExpectedType = 'Hex7'; Expected = @('one', 'two') }
|
||||
) {
|
||||
$result = Convert-RegValueData -valueData $ValueData
|
||||
|
||||
$result.ValueType | Should -Be $ExpectedType
|
||||
$result.ValueData | Should -Be $Expected
|
||||
}
|
||||
|
||||
It '<Case>' -ForEach @(
|
||||
@{ Case = 'parses a registry value deletion'; ValueData = '-'; ExpectedOperation = 'DeleteValue' }
|
||||
@{ Case = 'ignores unsupported data'; ValueData = 'hex(b):not-hex'; ExpectedOperation = $null }
|
||||
@{ Case = 'rejects hex data with an empty byte token'; ValueData = 'hex:01,,ff'; ExpectedOperation = $null }
|
||||
) {
|
||||
$result = Convert-RegValueData -valueData $ValueData
|
||||
if ($ExpectedOperation) {
|
||||
$result.OperationType | Should -Be $ExpectedOperation
|
||||
$result.ValueType | Should -BeNullOrEmpty
|
||||
}
|
||||
else {
|
||||
$result | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Convert-HexStringToByteArray' {
|
||||
It 'rejects empty and malformed hex tokens' -ForEach @('01,,ff', ',01', '01,', '01,gg') {
|
||||
Convert-HexStringToByteArray -hexValue $_ | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'converts byte arrays to registry strings and multi-strings' {
|
||||
Convert-RegistryByteArrayToString -byteData ([byte[]](65, 0, 0, 0)) | Should -Be 'A'
|
||||
Convert-RegistryByteArrayToMultiString -byteData ([byte[]](65, 0, 0, 0, 66, 0, 0, 0, 0, 0)) | Should -Be @('A', 'B')
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RegFileOperations' {
|
||||
It 'warns when it skips malformed registry value data' {
|
||||
$regFilePath = Join-Path $TestDrive 'malformed.reg'
|
||||
@'
|
||||
Windows Registry Editor Version 5.00
|
||||
|
||||
[HKEY_CURRENT_USER\Software\Example]
|
||||
"Broken"=hex:01,,ff
|
||||
'@ | Set-Content -LiteralPath $regFilePath -Encoding UTF8
|
||||
|
||||
Mock Write-Warning {}
|
||||
@(Get-RegFileOperations -regFilePath $regFilePath) | Should -BeNullOrEmpty
|
||||
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like "Skipping unsupported or malformed registry value 'Broken'*" }
|
||||
}
|
||||
|
||||
It 'parses key deletion, value deletion, and continued hex values' {
|
||||
$regFilePath = Join-Path $TestDrive 'settings.reg'
|
||||
@'
|
||||
Windows Registry Editor Version 5.00
|
||||
|
||||
[-HKEY_CURRENT_USER\Software\Example\Removed]
|
||||
|
||||
[HKEY_CURRENT_USER\Software\Example]
|
||||
"Enabled"=dword:00000001
|
||||
@=-
|
||||
"Bytes"=hex:01,\
|
||||
02,03
|
||||
'@ | Set-Content -LiteralPath $regFilePath -Encoding UTF8
|
||||
|
||||
$operations = @(Get-RegFileOperations -regFilePath $regFilePath)
|
||||
|
||||
$operations.Count | Should -Be 4
|
||||
$operations[0].OperationType | Should -Be 'DeleteKey'
|
||||
$operations[1].ValueName | Should -Be 'Enabled'
|
||||
$operations[1].ValueData | Should -Be 1
|
||||
$operations[2].OperationType | Should -Be 'DeleteValue'
|
||||
$operations[2].ValueName | Should -Be ''
|
||||
$operations[3].ValueData | Should -Be ([byte[]](1, 2, 3))
|
||||
}
|
||||
|
||||
It 'handles comments, default-value assignment, malformed lines, and deleted-key contents' {
|
||||
$regFilePath = Join-Path $TestDrive 'edge-cases.reg'
|
||||
@'
|
||||
Windows Registry Editor Version 5.00
|
||||
; comment
|
||||
|
||||
[HKEY_CURRENT_USER\Software\Example]
|
||||
@="default"
|
||||
malformed line
|
||||
|
||||
[-HKEY_CURRENT_USER\Software\Removed]
|
||||
"Ignored"="value"
|
||||
'@ | Set-Content -LiteralPath $regFilePath -Encoding UTF8
|
||||
|
||||
$operations = @(Get-RegFileOperations -regFilePath $regFilePath)
|
||||
|
||||
$operations | Should -HaveCount 2
|
||||
$operations[0].OperationType | Should -Be 'SetValue'
|
||||
$operations[0].ValueName | Should -Be ''
|
||||
$operations[0].ValueData | Should -Be 'default'
|
||||
$operations[1].OperationType | Should -Be 'DeleteKey'
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-TargetUserForAppRemoval.ps1')
|
||||
}
|
||||
|
||||
Describe 'Get-TargetUserForAppRemoval' {
|
||||
It '<Case>' -ForEach @(
|
||||
@{ Case = 'defaults to all users'; Params = @{}; Expected = 'AllUsers' }
|
||||
@{ Case = 'returns an explicit target unchanged'; Params = @{ AppRemovalTarget = 'Alice' }; Expected = 'Alice' }
|
||||
) {
|
||||
$script:Params = $Params
|
||||
Get-TargetUserForAppRemoval | Should -Be $Expected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-JsonFile.ps1')
|
||||
. (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'
|
||||
}
|
||||
|
||||
Describe 'Import-ConfigToParams' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:ModernStandbySupported = $false
|
||||
$script:Features = @{}
|
||||
foreach ($featureId in @(
|
||||
'DisableSettings365Ads', 'DisableSnapAssist', 'EnableDarkMode', 'ShowSearchBoxTb',
|
||||
'DisableTelemetry', 'DisableWidgets', 'DisableLockscreenTips', 'DisableSnapLayouts',
|
||||
'DisableAISvcAutoStart', 'DisableMouseAcceleration', 'DisableCopilot', 'DisableRecall'
|
||||
)) {
|
||||
$script:Features[$featureId] = [PSCustomObject]@{ FeatureId = $featureId; MinVersion = $null; MaxVersion = $null }
|
||||
}
|
||||
}
|
||||
|
||||
It 'loads the selected tweaks and deployment settings from an exported config file' {
|
||||
$result = Import-ConfigToParams -ConfigPath $script:ConfigFixturePath -CurrentBuild 22631
|
||||
|
||||
$result | Should -Be (Resolve-Path -LiteralPath $script:ConfigFixturePath).Path
|
||||
foreach ($featureId in @(
|
||||
'DisableSettings365Ads', 'DisableSnapAssist', 'EnableDarkMode', 'ShowSearchBoxTb',
|
||||
'DisableTelemetry', 'DisableWidgets', 'DisableLockscreenTips', 'DisableSnapLayouts',
|
||||
'DisableAISvcAutoStart', 'DisableMouseAcceleration', 'DisableCopilot', 'DisableRecall'
|
||||
)) {
|
||||
$script:Params[$featureId] | Should -BeTrue
|
||||
}
|
||||
$script:Params['CreateRestorePoint'] | Should -BeTrue
|
||||
$script:Params['NoRestartExplorer'] | Should -BeTrue
|
||||
$script:Params.ContainsKey('User') | Should -BeFalse
|
||||
$script:Params.ContainsKey('AppRemovalTarget') | Should -BeFalse
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
BeforeAll {
|
||||
$importJsonFileScriptPath = Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-JsonFile.ps1'
|
||||
$script:FixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading'
|
||||
. $importJsonFileScriptPath
|
||||
}
|
||||
|
||||
Describe 'Import-JsonFile' {
|
||||
BeforeEach {
|
||||
Mock Write-Error {}
|
||||
}
|
||||
|
||||
It 'loads valid JSON with the expected version' {
|
||||
$result = Import-JsonFile -filePath (Join-Path $script:FixturePath 'Config.Valid.json') -expectedVersion '1.0'
|
||||
|
||||
$result.Name | Should -Be 'Example configuration'
|
||||
}
|
||||
|
||||
It 'parses the <Kind> settings fixture' -ForEach @(
|
||||
@{ Kind = 'default'; FileName = 'DefaultSettings.Valid.json' }
|
||||
@{ Kind = 'last-used'; FileName = 'LastUsedSettings.Valid.json' }
|
||||
) {
|
||||
$result = Import-JsonFile -filePath (Join-Path $script:FixturePath $FileName) -expectedVersion '1.0'
|
||||
|
||||
$result.Settings | Should -Not -BeNullOrEmpty
|
||||
$result.Settings[0].Name | Should -Be 'Supported'
|
||||
}
|
||||
|
||||
It 'returns null and reports an error for <Case>' -ForEach @(
|
||||
@{ Case = 'a version mismatch'; FileName = 'Config.VersionMismatch.json'; ExpectedVersion = '1.0'; Optional = $false; Error = 'version mismatch' }
|
||||
@{ Case = 'invalid JSON'; FileName = 'Config.Invalid.json'; ExpectedVersion = $null; Optional = $false; Error = 'Failed to parse JSON file' }
|
||||
) {
|
||||
$filePath = Join-Path $script:FixturePath $FileName
|
||||
$result = Import-JsonFile -filePath $filePath -expectedVersion $ExpectedVersion -optionalFile:$Optional
|
||||
|
||||
$result | Should -BeNullOrEmpty
|
||||
Should -Invoke Write-Error -Times 1 -Exactly -ParameterFilter { $Message -match $Error }
|
||||
}
|
||||
|
||||
It 'returns null without an error for an optional missing last-used settings file' {
|
||||
$result = Import-JsonFile -filePath (Join-Path $TestDrive 'LastUsedSettings.json') -expectedVersion '1.0' -optionalFile
|
||||
|
||||
$result | Should -BeNullOrEmpty
|
||||
Should -Invoke Write-Error -Times 0 -Exactly
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
BeforeAll {
|
||||
function Get-RegistryFilePathForFeature { param($RegistryKey) $RegistryKey }
|
||||
function Invoke-RegistryOperationsFromRegFile { param($RegFilePath) }
|
||||
function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject, [switch]$PassHiveContext) }
|
||||
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Import-RegistryFile.ps1')
|
||||
}
|
||||
|
||||
Describe 'Import-RegistryFile' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:RegistryImportFailures = 0
|
||||
$script:regPath = Join-Path $TestDrive 'feature.reg'
|
||||
'' | Set-Content -LiteralPath $script:regPath
|
||||
Mock Get-RegistryFilePathForFeature { $script:regPath }
|
||||
Mock Invoke-RegistryOperationsFromRegFile {}
|
||||
Mock Invoke-WithTargetUserHive {}
|
||||
Mock Invoke-NonBlocking { [PSCustomObject]@{ Output = @(); ExitCode = 0; Error = $null } }
|
||||
Mock Write-Host {}
|
||||
Mock Write-Warning {}
|
||||
}
|
||||
|
||||
It 'throws and increments the failure count when the registry file is missing' {
|
||||
Mock Get-RegistryFilePathForFeature { Join-Path $TestDrive 'missing.reg' }
|
||||
{ Import-RegistryFile -message 'Apply' -path 'missing.reg' } | Should -Throw 'Unable to find registry file:*'
|
||||
$script:RegistryImportFailures | Should -Be 1
|
||||
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'uses the PowerShell writer only in WhatIf mode' {
|
||||
$script:Params = @{ WhatIf = $true }
|
||||
Import-RegistryFile -message 'Apply' -path 'feature.reg'
|
||||
Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 1 -Exactly -ParameterFilter { $RegFilePath -eq $script:regPath }
|
||||
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'uses the PowerShell writer for an already-loaded target-user hive' {
|
||||
$script:Params = @{ User = 'Alice' }
|
||||
Mock Invoke-WithTargetUserHive {
|
||||
param($TargetUserName, $ScriptBlock, $ArgumentObject, $PassHiveContext)
|
||||
& $ScriptBlock $ArgumentObject ([PSCustomObject]@{ WasAlreadyLoaded = $true })
|
||||
}
|
||||
|
||||
Import-RegistryFile -message 'Apply' -path 'feature.reg'
|
||||
|
||||
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Alice' -and $PassHiveContext }
|
||||
Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 1 -Exactly
|
||||
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'falls back to the PowerShell writer when reg import fails' {
|
||||
Mock Invoke-NonBlocking { [PSCustomObject]@{ Output = @('denied'); ExitCode = 5; Error = 'access denied' } }
|
||||
|
||||
Import-RegistryFile -message 'Apply' -path 'feature.reg'
|
||||
|
||||
Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 1 -Exactly
|
||||
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like "reg import failed*" }
|
||||
$script:RegistryImportFailures | Should -Be 0
|
||||
}
|
||||
|
||||
It 'does not invoke the fallback after a successful reg import' {
|
||||
Import-RegistryFile -message 'Apply' -path 'feature.reg'
|
||||
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly
|
||||
Should -Invoke Invoke-RegistryOperationsFromRegFile -Times 0 -Exactly
|
||||
$script:RegistryImportFailures | Should -Be 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
BeforeAll {
|
||||
function Import-RegistryFile { param($Message, $path) }
|
||||
function Remove-SelectedApps { param($Apps) }
|
||||
function Disable-TelemetryScheduledTasks {}
|
||||
function Enable-TelemetryScheduledTasks {}
|
||||
function Generate-AppsList { @() }
|
||||
function Get-FriendlyTargetUserName { 'current user' }
|
||||
function EnableStoreSearchSuggestionsForAllUsers {}
|
||||
function Set-StoreSearchSuggestionsEnabled { param($StoreAppsDatabase) }
|
||||
function Get-StoreAppsDatabasePathForUser { param($UserName) 'store.db' }
|
||||
function Get-UserName { 'Alice' }
|
||||
function Disable-WindowsFeature { param($FeatureName) }
|
||||
function New-RegistrySettingsBackup { param($ActionableKeys, $ExtraFeatures) }
|
||||
function Invoke-SystemRestorePoint {}
|
||||
function Enable-WindowsFeature { param($FeatureName) }
|
||||
function Get-StartMenuBinPathForUser { param($UserName) 'start.bin' }
|
||||
function Replace-StartMenu { param($startMenuBinFile, $startMenuTemplate) }
|
||||
function Replace-StartMenuForAllUsers { param($startMenuTemplate) }
|
||||
function DisableStoreSearchSuggestionsForAllUsers {}
|
||||
function Set-StoreSearchSuggestionsDisabled { param($StoreAppsDatabase) }
|
||||
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-Changes.ps1')
|
||||
}
|
||||
|
||||
Describe 'Resolve-UndoRegFilePath' {
|
||||
BeforeEach {
|
||||
$script:RegfilesPath = $TestDrive
|
||||
New-Item -ItemType Directory -Path (Join-Path $TestDrive 'Undo') -Force | Out-Null
|
||||
}
|
||||
|
||||
It '<Case>' -ForEach @(
|
||||
@{ Case = 'prefers an existing file in Undo'; FileName = 'feature.reg'; CreateUndoFile = $true; Expected = 'Undo\feature.reg' }
|
||||
@{ Case = 'falls back to the original file name'; FileName = 'missing.reg'; CreateUndoFile = $false; Expected = 'missing.reg' }
|
||||
) {
|
||||
if ($CreateUndoFile) {
|
||||
'' | Set-Content -LiteralPath (Join-Path $TestDrive "Undo\$FileName")
|
||||
}
|
||||
|
||||
Resolve-UndoRegFilePath -FileName $FileName | Should -Be $Expected
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-FeatureApply' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:Features = @{
|
||||
RegistryFeature = [PSCustomObject]@{ ApplyText = 'Apply registry feature'; RegistryKey = 'feature.reg' }
|
||||
DisableTelemetry = [PSCustomObject]@{ ApplyText = 'Disable telemetry'; RegistryKey = 'telemetry.reg' }
|
||||
DisableBing = [PSCustomObject]@{ ApplyText = 'Disable Bing'; RegistryKey = 'bing.reg' }
|
||||
DisableCopilot = [PSCustomObject]@{ ApplyText = 'Disable Copilot'; RegistryKey = 'copilot.reg' }
|
||||
RemoveApps = [PSCustomObject]@{ ApplyText = 'Remove apps'; RegistryKey = '' }
|
||||
RemoveGamingApps = [PSCustomObject]@{ ApplyText = 'Remove gaming'; RegistryKey = '' }
|
||||
RemoveHPApps = [PSCustomObject]@{ ApplyText = 'Remove HP'; RegistryKey = '' }
|
||||
DisableWidgets = [PSCustomObject]@{ ApplyText = 'Disable widgets'; RegistryKey = '' }
|
||||
EnableWindowsSandbox = [PSCustomObject]@{ ApplyText = 'Enable Sandbox'; RegistryKey = '' }
|
||||
EnableWindowsSubsystemForLinux = [PSCustomObject]@{ ApplyText = 'Enable WSL'; RegistryKey = '' }
|
||||
ClearStart = [PSCustomObject]@{ ApplyText = 'Clear Start'; RegistryKey = '' }
|
||||
ReplaceStart = [PSCustomObject]@{ ApplyText = 'Replace Start'; RegistryKey = '' }
|
||||
ClearStartAllUsers = [PSCustomObject]@{ ApplyText = 'Clear Start all users'; RegistryKey = '' }
|
||||
ReplaceStartAllUsers = [PSCustomObject]@{ ApplyText = 'Replace Start all users'; RegistryKey = '' }
|
||||
DisableStoreSearchSuggestions = [PSCustomObject]@{ ApplyText = 'Disable Store suggestions'; RegistryKey = '' }
|
||||
}
|
||||
Mock Import-RegistryFile {}
|
||||
Mock Remove-SelectedApps {}
|
||||
Mock Disable-TelemetryScheduledTasks {}
|
||||
Mock Generate-AppsList { @() }
|
||||
Mock Get-FriendlyTargetUserName { 'current user' }
|
||||
Mock Enable-WindowsFeature {}
|
||||
Mock Get-StartMenuBinPathForUser { 'start.bin' }
|
||||
Mock Get-UserName { 'Alice' }
|
||||
Mock Replace-StartMenu {}
|
||||
Mock Replace-StartMenuForAllUsers {}
|
||||
Mock DisableStoreSearchSuggestionsForAllUsers {}
|
||||
Mock Set-StoreSearchSuggestionsDisabled {}
|
||||
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
|
||||
Mock Get-Process { @() }
|
||||
Mock Stop-Process {}
|
||||
Mock Write-Host {}
|
||||
}
|
||||
|
||||
It 'imports a registry-backed feature' {
|
||||
Invoke-FeatureApply -FeatureId 'RegistryFeature'
|
||||
|
||||
Should -Invoke Import-RegistryFile -Times 1 -Exactly -ParameterFilter { $path -eq 'feature.reg' }
|
||||
Should -Invoke Remove-SelectedApps -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'runs the telemetry side effect after importing its registry file' {
|
||||
Invoke-FeatureApply -FeatureId 'DisableTelemetry'
|
||||
|
||||
Should -Invoke Import-RegistryFile -Times 1 -Exactly
|
||||
Should -Invoke Disable-TelemetryScheduledTasks -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'does not call app removal when the generated selection is empty' {
|
||||
Invoke-FeatureApply -FeatureId 'RemoveApps'
|
||||
|
||||
Should -Invoke Generate-AppsList -Times 1 -Exactly
|
||||
Should -Invoke Remove-SelectedApps -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'passes a non-empty generated selection to app removal' {
|
||||
Mock Generate-AppsList { @('One.App', 'Two.App') }
|
||||
|
||||
Invoke-FeatureApply -FeatureId 'RemoveApps'
|
||||
|
||||
Should -Invoke Remove-SelectedApps -Times 1 -Exactly -ParameterFilter { @($Apps).Count -eq 2 }
|
||||
}
|
||||
|
||||
It 'runs registry-backed companion app removal for <FeatureId>' -ForEach @(
|
||||
@{ FeatureId = 'DisableBing'; ExpectedApps = @('Microsoft.BingSearch') }
|
||||
@{ FeatureId = 'DisableCopilot'; ExpectedApps = @('Microsoft.Copilot', 'XP9CXNGPPJ97XX') }
|
||||
) {
|
||||
Invoke-FeatureApply -FeatureId $FeatureId
|
||||
Should -Invoke Import-RegistryFile -Times 1 -Exactly
|
||||
Should -Invoke Remove-SelectedApps -Times 1 -Exactly -ParameterFilter { @($Apps) -join ',' -eq $ExpectedApps -join ',' }
|
||||
}
|
||||
|
||||
It 'uses the expected static app list for <FeatureId>' -ForEach @(
|
||||
@{ FeatureId = 'RemoveGamingApps'; MinimumCount = 3; ExpectedApp = 'Microsoft.GamingApp' }
|
||||
@{ FeatureId = 'RemoveHPApps'; MinimumCount = 10; ExpectedApp = 'AD2F1837.myHP' }
|
||||
@{ FeatureId = 'DisableWidgets'; MinimumCount = 3; ExpectedApp = 'MicrosoftWindows.Client.WebExperience' }
|
||||
) {
|
||||
Invoke-FeatureApply -FeatureId $FeatureId
|
||||
Should -Invoke Remove-SelectedApps -Times 1 -Exactly -ParameterFilter { @($Apps).Count -ge $MinimumCount -and $Apps -contains $ExpectedApp }
|
||||
}
|
||||
|
||||
It 'does not stop widget processes in WhatIf mode' {
|
||||
$script:Params = @{ WhatIf = $true }
|
||||
Invoke-FeatureApply -FeatureId 'DisableWidgets'
|
||||
Should -Invoke Get-Process -Times 0 -Exactly
|
||||
Should -Invoke Stop-Process -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'enables the expected optional Windows features' {
|
||||
Invoke-FeatureApply -FeatureId 'EnableWindowsSandbox'
|
||||
Invoke-FeatureApply -FeatureId 'EnableWindowsSubsystemForLinux'
|
||||
Should -Invoke Enable-WindowsFeature -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Containers-DisposableClientVM' }
|
||||
Should -Invoke Enable-WindowsFeature -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'VirtualMachinePlatform' }
|
||||
Should -Invoke Enable-WindowsFeature -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Microsoft-Windows-Subsystem-Linux' }
|
||||
}
|
||||
|
||||
It 'applies current-user Start layouts only when a target path resolves' {
|
||||
$script:Params = @{ ReplaceStart = 'template.bin' }
|
||||
Invoke-FeatureApply -FeatureId 'ClearStart'
|
||||
Invoke-FeatureApply -FeatureId 'ReplaceStart'
|
||||
Should -Invoke Replace-StartMenu -Times 1 -Exactly -ParameterFilter { $startMenuBinFile -eq 'start.bin' -and -not $startMenuTemplate }
|
||||
Should -Invoke Replace-StartMenu -Times 1 -Exactly -ParameterFilter { $startMenuBinFile -eq 'start.bin' -and $startMenuTemplate -eq 'template.bin' }
|
||||
|
||||
Mock Get-StartMenuBinPathForUser { $null }
|
||||
Invoke-FeatureApply -FeatureId 'ClearStart'
|
||||
Should -Invoke Replace-StartMenu -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'applies all-user Start templates correctly' {
|
||||
$script:Params = @{ ReplaceStartAllUsers = 'all-users.bin' }
|
||||
Invoke-FeatureApply -FeatureId 'ClearStartAllUsers'
|
||||
Invoke-FeatureApply -FeatureId 'ReplaceStartAllUsers'
|
||||
Should -Invoke Replace-StartMenuForAllUsers -Times 2 -Exactly
|
||||
Should -Invoke Replace-StartMenuForAllUsers -Times 1 -Exactly -ParameterFilter { $null -eq $startMenuTemplate }
|
||||
Should -Invoke Replace-StartMenuForAllUsers -Times 1 -Exactly -ParameterFilter { $startMenuTemplate -eq 'all-users.bin' }
|
||||
}
|
||||
|
||||
It 'applies Store-search scope to all users during Sysprep' {
|
||||
$script:Params = @{ Sysprep = $true }
|
||||
Invoke-FeatureApply -FeatureId 'DisableStoreSearchSuggestions'
|
||||
|
||||
Should -Invoke DisableStoreSearchSuggestionsForAllUsers -Times 1 -Exactly
|
||||
Should -Invoke Set-StoreSearchSuggestionsDisabled -Times 0 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-ApplyFeatures' {
|
||||
BeforeEach {
|
||||
$script:CancelRequested = $false
|
||||
$script:Features = @{
|
||||
One = [PSCustomObject]@{ ApplyText = 'Apply one' }
|
||||
Two = [PSCustomObject]@{ ApplyText = 'Apply two' }
|
||||
}
|
||||
$script:progressCalls = New-Object System.Collections.Generic.List[object]
|
||||
$script:ApplyProgressCallback = { param($Step, $Total, $Text) $script:progressCalls.Add(@($Step, $Total, $Text)) }
|
||||
Mock Invoke-FeatureApply {}
|
||||
}
|
||||
|
||||
It 'reports progress and applies each feature in order' {
|
||||
Invoke-ApplyFeatures -FeatureIds @('One', 'Two') -StartStep 3 -TotalSteps 5
|
||||
|
||||
Should -Invoke Invoke-FeatureApply -Times 2 -Exactly
|
||||
$script:progressCalls | Should -HaveCount 2
|
||||
$script:progressCalls[0] | Should -Be @(3, 5, 'Apply one')
|
||||
$script:progressCalls[1] | Should -Be @(4, 5, 'Apply two')
|
||||
}
|
||||
|
||||
It 'stops before processing work when cancellation was requested' {
|
||||
$script:CancelRequested = $true
|
||||
|
||||
Invoke-ApplyFeatures -FeatureIds @('One', 'Two') -StartStep 1 -TotalSteps 2
|
||||
|
||||
Should -Invoke Invoke-FeatureApply -Times 0 -Exactly
|
||||
$script:progressCalls | Should -HaveCount 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-UndoFeatures' {
|
||||
BeforeEach {
|
||||
$script:CancelRequested = $false
|
||||
$script:ApplyProgressCallback = $null
|
||||
$script:Features = @{
|
||||
RegistryUndo = [PSCustomObject]@{ UndoLabel = 'Undo registry'; ApplyUndoText = 'Restoring registry'; RegistryUndoKey = 'undo.reg' }
|
||||
CustomUndo = [PSCustomObject]@{ UndoLabel = 'Undo custom'; ApplyUndoText = ''; RegistryUndoKey = '' }
|
||||
}
|
||||
Mock Resolve-UndoRegFilePath { param($FileName) "Undo\$FileName" }
|
||||
Mock Import-RegistryFile {}
|
||||
Mock Invoke-FeatureUndo {}
|
||||
}
|
||||
|
||||
It 'imports registry undo data and still invokes custom undo side effects' {
|
||||
Invoke-UndoFeatures -FeatureIds @('RegistryUndo') -StartStep 1 -TotalSteps 1
|
||||
|
||||
Should -Invoke Import-RegistryFile -Times 1 -Exactly -ParameterFilter { $path -eq 'Undo\undo.reg' }
|
||||
Should -Invoke Invoke-FeatureUndo -Times 1 -Exactly -ParameterFilter { $FeatureId -eq 'RegistryUndo' }
|
||||
}
|
||||
|
||||
It 'handles unknown and custom features without attempting a registry import' {
|
||||
Invoke-UndoFeatures -FeatureIds @('CustomUndo', 'Unknown') -StartStep 1 -TotalSteps 2
|
||||
|
||||
Should -Invoke Import-RegistryFile -Times 0 -Exactly
|
||||
Should -Invoke Invoke-FeatureUndo -Times 2 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-FeatureUndo' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:Features = @{
|
||||
EnableWindowsSandbox = [PSCustomObject]@{ ApplyUndoText = 'Disable Sandbox' }
|
||||
EnableWindowsSubsystemForLinux = [PSCustomObject]@{ ApplyUndoText = 'Disable WSL' }
|
||||
DisableTelemetry = [PSCustomObject]@{}
|
||||
DisableStoreSearchSuggestions = [PSCustomObject]@{}
|
||||
}
|
||||
Mock EnableStoreSearchSuggestionsForAllUsers {}
|
||||
Mock Set-StoreSearchSuggestionsEnabled {}
|
||||
Mock Get-StoreAppsDatabasePathForUser { 'store.db' }
|
||||
Mock Get-UserName { 'Alice' }
|
||||
Mock Disable-WindowsFeature {}
|
||||
Mock Enable-TelemetryScheduledTasks {}
|
||||
Mock Write-Host {}
|
||||
}
|
||||
|
||||
It 'undoes Store search suggestions for the selected target scope' -ForEach @(
|
||||
@{ Params = @{ Sysprep = $true }; AllUsers = 1; CurrentUser = 0 }
|
||||
@{ Params = @{}; AllUsers = 0; CurrentUser = 1 }
|
||||
) {
|
||||
$script:Params = $Params
|
||||
Invoke-FeatureUndo -FeatureId 'DisableStoreSearchSuggestions'
|
||||
Should -Invoke EnableStoreSearchSuggestionsForAllUsers -Times $AllUsers -Exactly
|
||||
Should -Invoke Set-StoreSearchSuggestionsEnabled -Times $CurrentUser -Exactly -ParameterFilter { $StoreAppsDatabase -eq 'store.db' }
|
||||
}
|
||||
|
||||
It 'disables both WSL optional features in dependency-safe order' {
|
||||
$script:disabledFeatures = [System.Collections.Generic.List[string]]::new()
|
||||
Mock Disable-WindowsFeature { param($FeatureName) $script:disabledFeatures.Add($FeatureName) }
|
||||
Invoke-FeatureUndo -FeatureId 'EnableWindowsSubsystemForLinux'
|
||||
$script:disabledFeatures | Should -Be @('Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform')
|
||||
}
|
||||
|
||||
It 'disables Sandbox and re-enables telemetry tasks' {
|
||||
Invoke-FeatureUndo -FeatureId 'EnableWindowsSandbox'
|
||||
Invoke-FeatureUndo -FeatureId 'DisableTelemetry'
|
||||
Should -Invoke Disable-WindowsFeature -Times 1 -Exactly -ParameterFilter { $FeatureName -eq 'Containers-DisposableClientVM' }
|
||||
Should -Invoke Enable-TelemetryScheduledTasks -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-AllChanges' {
|
||||
BeforeEach {
|
||||
$script:Params = @{ RegistryApply = $true; CustomApply = $true }
|
||||
$script:UndoParams = @{ RegistryUndo = $true }
|
||||
$script:ControlParams = @('WhatIf', 'Silent', 'User', 'Sysprep')
|
||||
$script:Features = @{
|
||||
RegistryApply = [PSCustomObject]@{ RegistryKey = 'apply.reg' }
|
||||
CustomApply = [PSCustomObject]@{ RegistryKey = '' }
|
||||
RegistryUndo = [PSCustomObject]@{ RegistryUndoKey = 'undo.reg' }
|
||||
}
|
||||
$script:CancelRequested = $false
|
||||
$script:ApplyProgressCallback = $null
|
||||
Mock Test-RunningAsSystem { $false }
|
||||
Mock Resolve-UndoRegFilePath { param($FileName) "Undo\$FileName" }
|
||||
Mock New-RegistrySettingsBackup {}
|
||||
Mock Invoke-SystemRestorePoint {}
|
||||
Mock Invoke-ApplyFeatures {}
|
||||
Mock Invoke-UndoFeatures {}
|
||||
Mock Write-Host {}
|
||||
}
|
||||
|
||||
It 'backs up registry work before applying and undoing selected features' {
|
||||
$script:order = [System.Collections.Generic.List[string]]::new()
|
||||
Mock New-RegistrySettingsBackup { $script:order.Add('backup') }
|
||||
Mock Invoke-ApplyFeatures { $script:order.Add('apply') }
|
||||
Mock Invoke-UndoFeatures { $script:order.Add('undo') }
|
||||
|
||||
Invoke-AllChanges
|
||||
|
||||
$script:order | Should -Be @('backup', 'apply', 'undo')
|
||||
Should -Invoke New-RegistrySettingsBackup -Times 1 -Exactly -ParameterFilter {
|
||||
$ActionableKeys -contains 'RegistryApply' -and @($ExtraFeatures).Count -eq 1 -and $ExtraFeatures[0].RegistryKey -eq 'Undo\undo.reg'
|
||||
}
|
||||
}
|
||||
|
||||
It 'prevents every mutation when registry backup creation fails' {
|
||||
Mock New-RegistrySettingsBackup { throw 'disk full' }
|
||||
{ Invoke-AllChanges } | Should -Throw 'Registry backup failed before applying changes.*disk full'
|
||||
Should -Invoke Invoke-ApplyFeatures -Times 0 -Exactly
|
||||
Should -Invoke Invoke-UndoFeatures -Times 0 -Exactly
|
||||
Should -Invoke Invoke-SystemRestorePoint -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'does not run when cancellation was already requested' {
|
||||
$script:CancelRequested = $true
|
||||
Invoke-AllChanges
|
||||
Should -Invoke New-RegistrySettingsBackup -Times 0 -Exactly
|
||||
Should -Invoke Invoke-ApplyFeatures -Times 0 -Exactly
|
||||
Should -Invoke Invoke-UndoFeatures -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'does not enter the undo phase when cancellation occurs during apply' {
|
||||
Mock Invoke-ApplyFeatures { $script:CancelRequested = $true }
|
||||
Invoke-AllChanges
|
||||
Should -Invoke Invoke-ApplyFeatures -Times 1 -Exactly
|
||||
Should -Invoke Invoke-UndoFeatures -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'rejects SYSTEM execution without an explicit user target' {
|
||||
Mock Test-RunningAsSystem { $true }
|
||||
{ Invoke-AllChanges } | Should -Throw "Win11Debloat is running as the SYSTEM account*"
|
||||
Should -Invoke New-RegistrySettingsBackup -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'allows SYSTEM execution with an explicit target and filters control parameters from features' {
|
||||
Mock Test-RunningAsSystem { $true }
|
||||
$script:Params = @{ User = 'Alice'; WhatIf = $true; CustomApply = $true }
|
||||
Invoke-AllChanges
|
||||
Should -Invoke New-RegistrySettingsBackup -Times 0 -Exactly
|
||||
Should -Invoke Invoke-ApplyFeatures -Times 1 -Exactly -ParameterFilter {
|
||||
@($FeatureIds).Count -eq 1 -and $FeatureIds[0] -eq 'CustomApply'
|
||||
}
|
||||
}
|
||||
|
||||
It 'sequences an optional restore point before feature application' {
|
||||
$script:Params = @{ CreateRestorePoint = $true; CustomApply = $true }
|
||||
$script:UndoParams = @{}
|
||||
$script:order = [System.Collections.Generic.List[string]]::new()
|
||||
Mock Invoke-SystemRestorePoint { $script:order.Add('restore-point') }
|
||||
Mock Invoke-ApplyFeatures { $script:order.Add('apply') }
|
||||
Invoke-AllChanges
|
||||
$script:order | Should -Be @('restore-point', 'apply')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
BeforeAll {
|
||||
Add-Type -AssemblyName PresentationFramework
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-AppSelection.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-Deployment.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-Navigation.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-TweaksBuilder.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\GUI\Set-WindowThemeResources.ps1')
|
||||
|
||||
function New-TestWindow {
|
||||
$window = New-Object System.Windows.Window
|
||||
[System.Windows.NameScope]::SetNameScope($window, [System.Windows.NameScope]::new())
|
||||
$window.Resources['ProgressActiveColor'] = [System.Windows.Media.Brushes]::Green
|
||||
$window.Resources['ProgressInactiveColor'] = [System.Windows.Media.Brushes]::Gray
|
||||
return $window
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'ConvertTo-NormalizedCheckboxState' {
|
||||
It 'normalizes indeterminate tri-state checkboxes to checked' {
|
||||
$checkBox = New-Object System.Windows.Controls.CheckBox
|
||||
$checkBox.IsThreeState = $true
|
||||
$checkBox.IsChecked = $null
|
||||
Add-Member -InputObject $checkBox -MemberType NoteProperty -Name 'WasIndeterminateBeforeClick' -Value $true
|
||||
|
||||
ConvertTo-NormalizedCheckboxState -CheckBox $checkBox | Should -BeTrue
|
||||
$checkBox.IsChecked | Should -BeTrue
|
||||
$checkBox.WasIndeterminateBeforeClick | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'sets tri-state preset checkboxes for empty, partial, and complete selections' {
|
||||
$checkBox = New-Object System.Windows.Controls.CheckBox
|
||||
$checkBox.IsThreeState = $true
|
||||
|
||||
Set-TriStatePresetCheckBoxState -CheckBox $checkBox -Total 0 -Selected 0
|
||||
$checkBox.IsEnabled | Should -BeFalse
|
||||
Set-TriStatePresetCheckBoxState -CheckBox $checkBox -Total 3 -Selected 1
|
||||
$checkBox.IsChecked.HasValue | Should -BeFalse
|
||||
Set-TriStatePresetCheckBoxState -CheckBox $checkBox -Total 3 -Selected 3
|
||||
$checkBox.IsChecked | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'rebuilds highlighted-search indexes and sorts app controls' {
|
||||
$panel = New-Object System.Windows.Controls.StackPanel
|
||||
$first = New-Object System.Windows.Controls.CheckBox
|
||||
$first.Content = 'Zeta'
|
||||
$first | Add-Member NoteProperty AppName 'Zeta'
|
||||
$first | Add-Member NoteProperty AppDescription 'Last'
|
||||
$first | Add-Member NoteProperty AppIdDisplay 'Zeta.App'
|
||||
$first.Background = [System.Windows.Media.Brushes]::Yellow
|
||||
$second = New-Object System.Windows.Controls.CheckBox
|
||||
$second.Content = 'Alpha'
|
||||
$second | Add-Member NoteProperty AppName 'Alpha'
|
||||
$second | Add-Member NoteProperty AppDescription 'First'
|
||||
$second | Add-Member NoteProperty AppIdDisplay 'Alpha.App'
|
||||
$second.Background = [System.Windows.Media.Brushes]::Yellow
|
||||
$null = $panel.Children.Add($first)
|
||||
$null = $panel.Children.Add($second)
|
||||
$script:AppSearchMatches = @()
|
||||
$script:AppSearchMatchIndex = -1
|
||||
$script:SortColumn = 'Name'
|
||||
$script:SortAscending = $true
|
||||
$nameArrow = New-Object System.Windows.Controls.TextBlock
|
||||
$descriptionArrow = New-Object System.Windows.Controls.TextBlock
|
||||
$appIdArrow = New-Object System.Windows.Controls.TextBlock
|
||||
$nameArrow.RenderTransform = New-Object System.Windows.Media.RotateTransform
|
||||
$descriptionArrow.RenderTransform = New-Object System.Windows.Media.RotateTransform
|
||||
$appIdArrow.RenderTransform = New-Object System.Windows.Media.RotateTransform
|
||||
|
||||
Update-AppsPanelRebuildSearchIndex -AppsPanel $panel -ActiveMatch $second
|
||||
Update-AppsPanelSort -AppsPanel $panel -SortArrowName $nameArrow -SortArrowDescription $descriptionArrow -SortArrowAppId $appIdArrow
|
||||
|
||||
$panel.Children[0].AppName | Should -Be 'Alpha'
|
||||
$script:AppSearchMatches.Count | Should -Be 2
|
||||
$script:AppSearchMatches[$script:AppSearchMatchIndex] | Should -Be $second
|
||||
$nameArrow.Opacity | Should -Be 1
|
||||
}
|
||||
|
||||
It 'finds matching combo-box content case-insensitively' {
|
||||
$comboBox = New-Object System.Windows.Controls.ComboBox
|
||||
$null = $comboBox.Items.Add('Disable telemetry')
|
||||
$null = $comboBox.Items.Add((New-Object System.Windows.Controls.ComboBoxItem -Property @{ Content = 'Enable widgets' }))
|
||||
|
||||
Test-ComboBoxContainsMatch -ComboBox $comboBox -SearchText 'telemetry' | Should -BeTrue
|
||||
Test-ComboBoxContainsMatch -ComboBox $comboBox -SearchText 'missing' | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-PendingTweakActions' {
|
||||
BeforeEach {
|
||||
$script:FeatureLabelLookup = @{ DisableTelemetry = 'Disable telemetry'; EnableWidgets = 'Enable widgets' }
|
||||
$script:UndoFeatureLabelLookup = @{ DisableTelemetry = 'Enable telemetry' }
|
||||
}
|
||||
|
||||
It 'builds pending apply and undo actions from mapped checkbox state' {
|
||||
$window = New-TestWindow
|
||||
$applyCheckBox = New-Object System.Windows.Controls.CheckBox
|
||||
$applyCheckBox.IsChecked = $true
|
||||
$applyCheckBox | Add-Member NoteProperty InitialState $false
|
||||
$undoCheckBox = New-Object System.Windows.Controls.CheckBox
|
||||
$undoCheckBox.IsChecked = $false
|
||||
$undoCheckBox | Add-Member NoteProperty InitialState $true
|
||||
$window.RegisterName('ApplyTelemetry', $applyCheckBox)
|
||||
$window.RegisterName('UndoTelemetry', $undoCheckBox)
|
||||
$script:UiControlMappings = @{
|
||||
ApplyTelemetry = [PSCustomObject]@{ Type = 'feature'; FeatureId = 'DisableTelemetry' }
|
||||
UndoTelemetry = [PSCustomObject]@{ Type = 'feature'; FeatureId = 'DisableTelemetry' }
|
||||
}
|
||||
|
||||
$actions = @(Get-PendingTweakActions -Window $window -ShowAppliedTweaksMode:$false | Sort-Object Action)
|
||||
|
||||
$actions.Action | Should -Be @('Apply', 'Undo')
|
||||
$actions.Label | Should -Be @('Disable telemetry', 'Enable telemetry')
|
||||
}
|
||||
|
||||
It 'builds a category preset map for visible mapped controls' {
|
||||
$window = New-TestWindow
|
||||
$checkBox = New-Object System.Windows.Controls.CheckBox
|
||||
$checkBox.Visibility = 'Visible'
|
||||
$window.RegisterName('DisableTelemetryCheckBox', $checkBox)
|
||||
$script:UiControlMappings = @{ DisableTelemetryCheckBox = [PSCustomObject]@{ Category = 'Privacy'; Type = 'feature'; FeatureId = 'DisableTelemetry' } }
|
||||
|
||||
$map = Get-CategoryTweakPresetMap -Window $window -Category 'Privacy'
|
||||
|
||||
$map['DisableTelemetryCheckBox'].ControlType | Should -Be 'CheckBox'
|
||||
$map['DisableTelemetryCheckBox'].DesiredValue | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'updates navigation visibility and progress indicators for an interior tab' {
|
||||
$window = New-TestWindow
|
||||
$tabControl = New-Object System.Windows.Controls.TabControl
|
||||
0..3 | ForEach-Object { $null = $tabControl.Items.Add((New-Object System.Windows.Controls.TabItem)) }
|
||||
$tabControl.SelectedIndex = 2
|
||||
foreach ($name in @('PreviousBtn', 'NextBtn', 'BottomNavGrid')) { $window.RegisterName($name, (New-Object System.Windows.Controls.Border)) }
|
||||
foreach ($name in @('ProgressIndicator1', 'ProgressIndicator2', 'ProgressIndicator3')) { $window.RegisterName($name, (New-Object System.Windows.Shapes.Rectangle)) }
|
||||
|
||||
Update-NavigationButtons -Window $window -TabControl $tabControl
|
||||
|
||||
$window.FindName('PreviousBtn').Visibility | Should -Be 'Visible'
|
||||
$window.FindName('NextBtn').Visibility | Should -Be 'Visible'
|
||||
$window.FindName('ProgressIndicator1').Fill | Should -Be $window.Resources['ProgressActiveColor']
|
||||
$window.FindName('ProgressIndicator3').Fill | Should -Be $window.Resources['ProgressInactiveColor']
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Set-WindowThemeResources' {
|
||||
It 'populates themed resources and selects the Windows 11 icon font' {
|
||||
$window = New-TestWindow
|
||||
$script:SharedStylesSchema = $null
|
||||
Mock Get-ItemPropertyValue { 22631 }
|
||||
|
||||
Set-WindowThemeResources -window $window -usesDarkMode:$true
|
||||
|
||||
$window.Resources['AppBgColor'] | Should -Not -BeNullOrEmpty
|
||||
$window.Resources['AppIconFontFamily'].Source | Should -Be 'Segoe Fluent Icons'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\GUI\MainWindow-Deployment.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\GUI\Get-SystemUsesDarkMode.ps1')
|
||||
}
|
||||
|
||||
Describe 'Get-UndoFeatureLabel' {
|
||||
BeforeEach {
|
||||
$script:UndoFeatureLabelLookup = @{ DisableTelemetry = 'Enable telemetry' }
|
||||
$script:FeatureLabelLookup = @{ DisableTelemetry = 'Disable telemetry'; DisableWidgets = 'Disable widgets' }
|
||||
}
|
||||
|
||||
It 'prefers undo labels and falls back to feature labels' {
|
||||
Get-UndoFeatureLabel -FeatureId 'DisableTelemetry' | Should -Be 'Enable telemetry'
|
||||
Get-UndoFeatureLabel -FeatureId 'DisableWidgets' | Should -Be 'Disable widgets'
|
||||
}
|
||||
|
||||
It 'reads selected app IDs from string or array settings and removes blanks' {
|
||||
$stringSettings = [PSCustomObject]@{ Settings = @([PSCustomObject]@{ Name = 'Apps'; Value = ' One.App, ,Two.App ' }) }
|
||||
$arraySettings = [PSCustomObject]@{ Settings = @([PSCustomObject]@{ Name = 'Apps'; Value = @(' One.App ', '', 'Two.App') }) }
|
||||
|
||||
Get-SavedAppIdsFromSettingsJson -SettingsJson $stringSettings | Should -Be @('One.App', 'Two.App')
|
||||
Get-SavedAppIdsFromSettingsJson -SettingsJson $arraySettings | Should -Be @('One.App', 'Two.App')
|
||||
Get-SavedAppIdsFromSettingsJson -SettingsJson ([PSCustomObject]@{ Settings = @() }) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'returns the AppsUseLightTheme registry preference and fails closed' {
|
||||
Mock Get-ItemProperty { [PSCustomObject]@{ AppsUseLightTheme = 0 } }
|
||||
Get-SystemUsesDarkMode | Should -BeTrue
|
||||
|
||||
Mock Get-ItemProperty { throw 'Registry unavailable' }
|
||||
Get-SystemUsesDarkMode | Should -BeFalse
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-RegFileOperations.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\User-HiveHelpers.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-Changes.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Backup-RegistryFeatureSelection.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Save-ToFile.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Backup-RegistryState.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Backup-RegistrySnapshotCapture.ps1')
|
||||
}
|
||||
|
||||
Describe 'Get-SelectedFeatures' {
|
||||
BeforeEach {
|
||||
$script:Features = @{
|
||||
First = [PSCustomObject]@{ FeatureId = 'One'; RegistryKey = 'one.reg' }
|
||||
Duplicate = [PSCustomObject]@{ FeatureId = 'one'; RegistryKey = 'duplicate.reg' }
|
||||
Empty = $null
|
||||
}
|
||||
}
|
||||
|
||||
It 'keeps the first matching feature and ignores unknown, null, and duplicate keys' {
|
||||
$result = @(Get-SelectedFeatures -ActionableKeys @('missing', 'First', 'Duplicate', 'Empty'))
|
||||
|
||||
$result | Should -HaveCount 1
|
||||
$result[0].RegistryKey | Should -Be 'one.reg'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RegistryBackupPayload' {
|
||||
BeforeAll {
|
||||
$script:OriginalComputerName = $env:COMPUTERNAME
|
||||
}
|
||||
|
||||
BeforeEach {
|
||||
Mock Get-RegistryBackedFeatures { param($Features) @($Features | Where-Object RegistryKey) }
|
||||
Mock Get-RegistryBackupCapturePlans { @([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Example' }) }
|
||||
Mock Get-RegistrySnapshotsForBackup { @([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Example'; Exists = $true }) }
|
||||
Mock Get-RegistryBackupTargetDescription { 'CurrentUser:Tester' }
|
||||
$env:COMPUTERNAME = 'TestComputer'
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
$env:COMPUTERNAME = $script:OriginalComputerName
|
||||
}
|
||||
|
||||
It 'builds a versioned payload and preserves distinct apply and undo IDs' {
|
||||
$apply = @([PSCustomObject]@{ FeatureId = 'One'; RegistryKey = 'one.reg' }, [PSCustomObject]@{ FeatureId = 'one'; RegistryKey = 'other.reg' })
|
||||
$undo = @([PSCustomObject]@{ FeatureId = 'UndoOne'; RegistryUndoKey = 'undo.reg' })
|
||||
|
||||
$result = Get-RegistryBackupPayload -SelectedFeatures $apply -UndoFeatures $undo -CreatedAt ([datetime]'2026-01-02T03:04:05Z')
|
||||
|
||||
$result.Version | Should -Be '1.0'
|
||||
$result.BackupType | Should -Be 'RegistryState'
|
||||
$result.Target | Should -Be 'CurrentUser:Tester'
|
||||
$result.SelectedFeatures | Should -Be 'One'
|
||||
$result.SelectedUndoFeatures | Should -Be 'UndoOne'
|
||||
$result.RegistryKeys | Should -HaveCount 1
|
||||
}
|
||||
|
||||
It 'does not add SelectedUndoFeatures when no undo features were supplied' {
|
||||
$result = Get-RegistryBackupPayload -SelectedFeatures @() -UndoFeatures @() -CreatedAt (Get-Date)
|
||||
|
||||
$result.ContainsKey('SelectedUndoFeatures') | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'New-RegistrySettingsBackup' {
|
||||
BeforeEach {
|
||||
$script:RegistryBackupsPath = Join-Path $TestDrive 'Backups'
|
||||
$script:Features = @{ Feature = [PSCustomObject]@{ FeatureId = 'Feature'; RegistryKey = 'feature.reg' } }
|
||||
Mock Get-RegistryBackupPayload { @{ Version = '1.0' } }
|
||||
Mock Save-ToFile { $true }
|
||||
Mock Write-Host {}
|
||||
}
|
||||
|
||||
It 'returns null and does not write when no selected feature has registry data' {
|
||||
$script:Features.Feature.RegistryKey = ''
|
||||
|
||||
New-RegistrySettingsBackup -ActionableKeys @('Feature') | Should -BeNullOrEmpty
|
||||
Should -Invoke Save-ToFile -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'creates the backup directory and saves a generated payload' {
|
||||
$result = New-RegistrySettingsBackup -ActionableKeys @('Feature')
|
||||
|
||||
$result | Should -Match 'Win11Debloat-RegistryBackup-\d{8}_\d{6}\.json$'
|
||||
Test-Path -LiteralPath $script:RegistryBackupsPath | Should -BeTrue
|
||||
Should -Invoke Save-ToFile -Times 1 -Exactly -ParameterFilter { $FilePath -eq $result -and $MaxDepth -eq 25 }
|
||||
}
|
||||
|
||||
It 'throws when persistence reports failure' {
|
||||
Mock Save-ToFile { $false }
|
||||
|
||||
{ New-RegistrySettingsBackup -ActionableKeys @('Feature') } | Should -Throw 'Failed to save registry backup to *'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Add-RegistryPlanOperation' {
|
||||
It 'merges duplicate value operations and upgrades a key deletion to a recursive capture' {
|
||||
$plans = @{}
|
||||
Add-RegistryPlanOperation -PlanMap $plans -Operation ([PSCustomObject]@{ KeyPath = 'HKEY_CURRENT_USER\Software\Example'; OperationType = 'SetValue'; ValueName = 'One' })
|
||||
Add-RegistryPlanOperation -PlanMap $plans -Operation ([PSCustomObject]@{ KeyPath = 'HKEY_CURRENT_USER\Software\Example'; OperationType = 'DeleteValue'; ValueName = 'one' })
|
||||
Add-RegistryPlanOperation -PlanMap $plans -Operation ([PSCustomObject]@{ KeyPath = 'HKEY_CURRENT_USER\Software\Example'; OperationType = 'DeleteKey'; ValueName = $null })
|
||||
|
||||
$plan = $plans['hkey_current_user\software\example']
|
||||
$plan.IncludeSubKeys | Should -BeTrue
|
||||
$plan.CaptureAllValues | Should -BeTrue
|
||||
$plan.ValueNames | Should -HaveCount 1
|
||||
}
|
||||
|
||||
It '<Case>' -ForEach @(
|
||||
@{ Case = 'uses an explicit undo key'; RegistryUndoKey = 'undo.reg'; RegistryKey = 'apply.reg'; Expected = 'Undo/undo.reg' }
|
||||
@{ Case = 'falls back to a relative regular registry key'; RegistryUndoKey = ''; RegistryKey = 'apply.reg'; Expected = 'apply.reg' }
|
||||
@{ Case = 'preserves a rooted regular registry key'; RegistryUndoKey = ''; RegistryKey = 'C:\temp\apply.reg'; Expected = 'C:\temp\apply.reg' }
|
||||
) {
|
||||
$script:RegfilesPath = $TestDrive
|
||||
function Resolve-UndoRegFilePath { param($FileName) "Undo/$FileName" }
|
||||
|
||||
$expectedPath = if ([System.IO.Path]::IsPathRooted($Expected)) { $Expected } else { Join-Path $TestDrive $Expected }
|
||||
Resolve-RegistryBackupUndoFilePath -Feature ([PSCustomObject]@{ RegistryUndoKey = $RegistryUndoKey; RegistryKey = $RegistryKey }) |
|
||||
Should -Be $expectedPath
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RegistryBackupCapturePlans' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:RegfilesPath = $TestDrive
|
||||
$script:applyPath = Join-Path $TestDrive 'apply.reg'
|
||||
$script:undoPath = Join-Path $TestDrive 'undo.reg'
|
||||
'' | Set-Content -LiteralPath $script:applyPath
|
||||
'' | Set-Content -LiteralPath $script:undoPath
|
||||
Mock Get-RegistryFilePathForFeature { $script:applyPath }
|
||||
Mock Resolve-RegistryBackupUndoFilePath { $script:undoPath }
|
||||
Mock Get-RegFileOperations {
|
||||
param($regFilePath)
|
||||
if ($regFilePath -eq $script:applyPath) {
|
||||
@(
|
||||
[PSCustomObject]@{ KeyPath = 'HKEY_CURRENT_USER\Software\Example'; OperationType = 'SetValue'; ValueName = 'Enabled' }
|
||||
[PSCustomObject]@{ KeyPath = $null; OperationType = 'SetValue'; ValueName = 'Ignored' }
|
||||
)
|
||||
}
|
||||
else {
|
||||
@([PSCustomObject]@{ KeyPath = 'hkey_current_user\software\example'; OperationType = 'DeleteValue'; ValueName = 'Removed' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It 'merges apply and undo operations for the same path and ignores operations without a path' {
|
||||
$plans = @(Get-RegistryBackupCapturePlans `
|
||||
-SelectedRegistryFeatures @([PSCustomObject]@{ RegistryKey = 'apply.reg' }) `
|
||||
-UndoRegistryFeatures @([PSCustomObject]@{ RegistryUndoKey = 'undo.reg' }))
|
||||
|
||||
$plans | Should -HaveCount 1
|
||||
$plans[0].ValueNames | Should -Contain 'Enabled'
|
||||
$plans[0].ValueNames | Should -Contain 'Removed'
|
||||
$plans[0].ValueNames | Should -Not -Contain 'Ignored'
|
||||
}
|
||||
|
||||
It 'passes Sysprep selection through to registry-file resolution' {
|
||||
Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @([PSCustomObject]@{ RegistryKey = 'apply.reg' }) -UseSysprepRegFiles | Out-Null
|
||||
|
||||
Should -Invoke Get-RegistryFilePathForFeature -Times 1 -Exactly -ParameterFilter { $UseSysprepRegFiles }
|
||||
}
|
||||
|
||||
It 'throws a descriptive error when an apply registry file is missing' {
|
||||
Mock Get-RegistryFilePathForFeature { Join-Path $TestDrive 'missing.reg' }
|
||||
|
||||
{ Get-RegistryBackupCapturePlans -SelectedRegistryFeatures @([PSCustomObject]@{ RegistryKey = 'missing.reg' }) } |
|
||||
Should -Throw 'Unable to find registry file for backup: missing.reg*'
|
||||
Should -Invoke Get-RegFileOperations -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'throws a descriptive error when an undo registry file is missing' {
|
||||
Mock Resolve-RegistryBackupUndoFilePath { Join-Path $TestDrive 'missing-undo.reg' }
|
||||
|
||||
{ Get-RegistryBackupCapturePlans -UndoRegistryFeatures @([PSCustomObject]@{ RegistryUndoKey = 'missing-undo.reg' }) } |
|
||||
Should -Throw 'Unable to find registry undo file for backup: missing-undo.reg*'
|
||||
}
|
||||
|
||||
It 'skips undo features that do not resolve to a registry file' {
|
||||
Mock Resolve-RegistryBackupUndoFilePath { $null }
|
||||
|
||||
@(Get-RegistryBackupCapturePlans -UndoRegistryFeatures @([PSCustomObject]@{ RegistryUndoKey = ''; RegistryKey = '' })) |
|
||||
Should -HaveCount 0
|
||||
Should -Invoke Get-RegFileOperations -Times 0 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RegistrySnapshotsForBackup' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
Mock Get-RegistryKeySnapshot {
|
||||
param($KeyPath)
|
||||
[PSCustomObject]@{ Path = $KeyPath; Exists = $true }
|
||||
}
|
||||
Mock Invoke-WithLoadedBackupHive {
|
||||
param($ScriptBlock, $ArgumentObject)
|
||||
& $ScriptBlock $ArgumentObject
|
||||
}
|
||||
}
|
||||
|
||||
It 'returns an empty collection without touching the registry when there are no plans' {
|
||||
@(Get-RegistrySnapshotsForBackup -CapturePlans @()) | Should -HaveCount 0
|
||||
Should -Invoke Get-RegistryKeySnapshot -Times 0 -Exactly
|
||||
Should -Invoke Invoke-WithLoadedBackupHive -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'captures each plan directly for the current user' {
|
||||
$plans = @(
|
||||
[PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\One'; CaptureAllValues = $true; ValueNames = @(); IncludeSubKeys = $true }
|
||||
[PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Two'; CaptureAllValues = $false; ValueNames = @('Value'); IncludeSubKeys = $false }
|
||||
)
|
||||
|
||||
@(Get-RegistrySnapshotsForBackup -CapturePlans $plans) | Should -HaveCount 2
|
||||
Should -Invoke Get-RegistryKeySnapshot -Times 2 -Exactly
|
||||
Should -Invoke Invoke-WithLoadedBackupHive -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'captures through the loaded-hive wrapper in <Case>' -ForEach @(
|
||||
@{ Case = 'User mode'; Params = @{ User = 'Alice' } }
|
||||
@{ Case = 'Sysprep mode'; Params = @{ Sysprep = $true } }
|
||||
) {
|
||||
$script:Params = $Params
|
||||
$plan = [PSCustomObject]@{ Path = 'HKEY_USERS\Default\Software\Example'; CaptureAllValues = $false; ValueNames = @('Value'); IncludeSubKeys = $false }
|
||||
|
||||
@(Get-RegistrySnapshotsForBackup -CapturePlans @($plan)) | Should -HaveCount 1
|
||||
Should -Invoke Invoke-WithLoadedBackupHive -Times 1 -Exactly
|
||||
Should -Invoke Get-RegistryKeySnapshot -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-WithLoadedBackupHive' {
|
||||
BeforeEach {
|
||||
Mock Invoke-WithTargetUserHive { param($TargetUserName) $TargetUserName }
|
||||
}
|
||||
|
||||
It 'targets <ExpectedUser> in <Case>' -ForEach @(
|
||||
@{ Case = 'Sysprep mode'; Params = @{ Sysprep = $true }; ExpectedUser = 'Default' }
|
||||
@{ Case = 'User mode'; Params = @{ User = 'Alice' }; ExpectedUser = 'Alice' }
|
||||
) {
|
||||
$script:Params = $Params
|
||||
Invoke-WithLoadedBackupHive -ScriptBlock { } | Should -Be $ExpectedUser
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
BeforeAll {
|
||||
$registryPathHelperScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1'
|
||||
$validationScriptPath = Join-Path $PSScriptRoot '..\Scripts\Features\Registry-BackupValidation.ps1'
|
||||
|
||||
. $registryPathHelperScriptPath
|
||||
. $validationScriptPath
|
||||
}
|
||||
|
||||
Describe 'Get-NormalizedSelectedFeatureIdsFromBackup' {
|
||||
It 'returns distinct feature IDs without changing their first occurrence' {
|
||||
$backup = [PSCustomObject]@{
|
||||
SelectedFeatures = @('DisableTelemetry', 'disabletelemetry', 'DisableCopilot')
|
||||
}
|
||||
|
||||
$result = Get-NormalizedSelectedFeatureIdsFromBackup -Backup $backup
|
||||
|
||||
$result.Errors | Should -BeNullOrEmpty
|
||||
$result.SelectedFeatures | Should -Be @('DisableTelemetry', 'DisableCopilot')
|
||||
}
|
||||
|
||||
It 'reports missing SelectedFeatures' {
|
||||
$result = Get-NormalizedSelectedFeatureIdsFromBackup -Backup ([PSCustomObject]@{})
|
||||
|
||||
$result.SelectedFeatures | Should -BeNullOrEmpty
|
||||
$result.Errors | Should -Contain 'Missing property: SelectedFeatures'
|
||||
}
|
||||
|
||||
It 'reports non-string and empty feature IDs' {
|
||||
$backup = [PSCustomObject]@{
|
||||
SelectedFeatures = @('DisableTelemetry', '', 42, $null)
|
||||
}
|
||||
|
||||
$result = Get-NormalizedSelectedFeatureIdsFromBackup -Backup $backup
|
||||
|
||||
$result.SelectedFeatures | Should -Be 'DisableTelemetry'
|
||||
$result.Errors | Should -Contain 'SelectedFeatures must contain non-empty string feature IDs.'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-NormalizedSelectedUndoFeatureIdsFromBackup' {
|
||||
It 'allows backups created before SelectedUndoFeatures was introduced' {
|
||||
$result = Get-NormalizedSelectedUndoFeatureIdsFromBackup -Backup ([PSCustomObject]@{})
|
||||
|
||||
$result.SelectedUndoFeatures | Should -BeNullOrEmpty
|
||||
$result.Errors | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'deduplicates undo feature IDs case-insensitively' {
|
||||
$backup = [PSCustomObject]@{
|
||||
SelectedUndoFeatures = @('EnableTelemetry', 'enabletelemetry')
|
||||
}
|
||||
|
||||
$result = Get-NormalizedSelectedUndoFeatureIdsFromBackup -Backup $backup
|
||||
|
||||
$result.Errors | Should -BeNullOrEmpty
|
||||
$result.SelectedUndoFeatures | Should -Be 'EnableTelemetry'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-SelectedRegistryFeaturesForBackupValidation' {
|
||||
BeforeEach {
|
||||
$script:Features = @{
|
||||
ApplyFeature = [PSCustomObject]@{ Id = 'ApplyFeature'; RegistryKey = 'ApplyFeature.reg' }
|
||||
UndoFeature = [PSCustomObject]@{ Id = 'UndoFeature'; RegistryKey = 'UndoFeature.reg'; RegistryUndoKey = 'UndoFeature.undo.reg' }
|
||||
FallbackUndoFeature = [PSCustomObject]@{ Id = 'FallbackUndoFeature'; RegistryKey = 'FallbackUndoFeature.reg'; RegistryUndoKey = '' }
|
||||
CustomFeature = [PSCustomObject]@{ Id = 'CustomFeature'; RegistryKey = '' }
|
||||
}
|
||||
}
|
||||
|
||||
It 'creates a case-insensitive registry value-name set' {
|
||||
$valueNames = ConvertTo-RegistryValueNameSet -ValueNames @('Enabled', 'enabled', 'Mode')
|
||||
|
||||
$valueNames.Count | Should -Be 2
|
||||
$valueNames.Contains('ENABLED') | Should -BeTrue
|
||||
$valueNames.Contains('mode') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'selects registry-backed apply features and reports unknown IDs' {
|
||||
$errors = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
$result = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @('ApplyFeature', 'CustomFeature', 'MissingFeature') -IsUndoFeature:$false -Errors $errors)
|
||||
|
||||
$result.Id | Should -Be 'ApplyFeature'
|
||||
$errors | Should -Contain "Selected feature 'MissingFeature' was not found in the current feature catalog."
|
||||
}
|
||||
|
||||
It 'uses undo registry keys and falls back to apply keys when needed' {
|
||||
$errors = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
$result = @(Get-SelectedRegistryFeaturesForBackupValidation -SelectedFeatureIds @('UndoFeature', 'FallbackUndoFeature') -IsUndoFeature:$true -Errors $errors)
|
||||
|
||||
$result.Id | Should -Be @('UndoFeature', 'FallbackUndoFeature')
|
||||
$errors | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-RegistryValueKindNameSupported' {
|
||||
It 'accepts supported registry value kind <KindName>' -ForEach @(
|
||||
@{ KindName = 'dword' }
|
||||
@{ KindName = 'QWord' }
|
||||
@{ KindName = 'MultiString' }
|
||||
) {
|
||||
Test-RegistryValueKindNameSupported -KindName $KindName | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'rejects <Case>' -ForEach @(
|
||||
@{ Case = 'a null kind name'; KindName = $null }
|
||||
@{ Case = 'an empty kind name'; KindName = '' }
|
||||
@{ Case = 'the Unknown registry kind'; KindName = 'Unknown' }
|
||||
@{ Case = 'the unsupported None registry kind'; KindName = 'None' }
|
||||
@{ Case = 'an invalid registry kind name'; KindName = 'NotARegistryValueKind' }
|
||||
) {
|
||||
Test-RegistryValueKindNameSupported -KindName $KindName | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-RegistryValueDataMatchesKind' {
|
||||
It 'accepts <Case>' -ForEach @(
|
||||
@{ Case = 'the largest DWord'; Kind = 'DWord'; Data = [uint32]::MaxValue }
|
||||
@{ Case = 'the largest QWord'; Kind = 'QWord'; Data = [uint64]::MaxValue }
|
||||
@{ Case = 'non-empty Binary bytes'; Kind = 'Binary'; Data = @(0, 255) }
|
||||
@{ Case = 'empty Binary bytes'; Kind = 'Binary'; Data = [byte[]]::new(0) }
|
||||
@{ Case = 'a string array'; Kind = 'MultiString'; Data = @('one', 'two') }
|
||||
) {
|
||||
Test-RegistryValueDataMatchesKind -KindName $Kind -Data $Data | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'rejects <Case>' -ForEach @(
|
||||
@{ Case = 'an overflowing DWord'; Kind = 'DWord'; Data = '4294967296' }
|
||||
@{ Case = 'a negative QWord'; Kind = 'QWord'; Data = -1 }
|
||||
@{ Case = 'null Binary data'; Kind = 'Binary'; Data = $null }
|
||||
@{ Case = 'an invalid binary byte'; Kind = 'Binary'; Data = @(0, 256) }
|
||||
@{ Case = 'an object in a MultiString'; Kind = 'MultiString'; Data = @('one', [PSCustomObject]@{}) }
|
||||
) {
|
||||
Test-RegistryValueDataMatchesKind -KindName $Kind -Data $Data | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-NormalizedRegistryValueName' {
|
||||
It 'normalizes a null value name to the default registry value' {
|
||||
Get-NormalizedRegistryValueName -ValueName $null | Should -Be ''
|
||||
Get-RegistryValueReferenceForError -SnapshotPath 'HKEY_CURRENT_USER\Software\Example' -ValueName '' |
|
||||
Should -Be 'HKEY_CURRENT_USER\Software\Example\\(Default)'
|
||||
}
|
||||
|
||||
It 'keeps a named registry value in the error reference' {
|
||||
Get-RegistryValueReferenceForError -SnapshotPath 'HKEY_CURRENT_USER\Software\Example' -ValueName 'Setting' |
|
||||
Should -Be 'HKEY_CURRENT_USER\Software\Example\\Setting'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Normalize-RegistryKeySnapshot' {
|
||||
It 'normalizes nested snapshots and supplies value defaults' {
|
||||
$snapshot = [PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'
|
||||
Exists = $true
|
||||
Values = @(
|
||||
[PSCustomObject]@{ Name = 'Enabled'; Kind = 'DWord'; Data = 1 }
|
||||
[PSCustomObject]@{ Name = 'Removed'; Exists = $false }
|
||||
)
|
||||
SubKeys = @(
|
||||
[PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example\Child'
|
||||
Exists = $true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
$result = Normalize-RegistryKeySnapshot -Snapshot $snapshot
|
||||
|
||||
$result.Path | Should -Be 'HKEY_CURRENT_USER\Software\Example'
|
||||
$result.Exists | Should -BeTrue
|
||||
$result.Values.Count | Should -Be 2
|
||||
$result.Values[0].Exists | Should -BeTrue
|
||||
$result.Values[0].Kind | Should -Be 'DWord'
|
||||
$result.Values[1].Exists | Should -BeFalse
|
||||
$result.Values[1].Kind | Should -BeNullOrEmpty
|
||||
$result.Values[1].Data | Should -BeNullOrEmpty
|
||||
$result.SubKeys[0].Path | Should -Be 'HKEY_CURRENT_USER\Software\Example\Child'
|
||||
$result.SubKeys[0].Values | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'rejects a snapshot without a registry path' {
|
||||
{ Normalize-RegistryKeySnapshot -Snapshot ([PSCustomObject]@{ Exists = $true }) } |
|
||||
Should -Throw 'Backup validation failed: Registry key snapshot is missing Path.'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-RegistrySnapshotAgainstAllowList' {
|
||||
It 'normalizes path separators and hive casing' {
|
||||
Get-NormalizedRegistryPathKey -Path 'hkey_current_user/Software/Example/' |
|
||||
Should -Match '^HKEY_CURRENT_USER\\+Software\\Example$'
|
||||
}
|
||||
|
||||
It 'matches configured value names case-insensitively' {
|
||||
$planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @(
|
||||
[PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'
|
||||
IncludeSubKeys = $false
|
||||
CaptureAllValues = $false
|
||||
ValueNames = @('Enabled')
|
||||
}
|
||||
)
|
||||
$planMatch = Find-RegistryAllowListPlanMatch -NormalizedPath (Get-NormalizedRegistryPathKey -Path 'HKEY_CURRENT_USER\Software\Example') -PlanMap $planMap
|
||||
|
||||
Test-RegistryValueAllowedByPlan -PlanMatch $planMatch -ValueName 'enabled' | Should -BeTrue
|
||||
Test-RegistryValueAllowedByPlan -PlanMatch $planMatch -ValueName 'Unexpected' | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'allows configured values at an exact path' {
|
||||
$planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @(
|
||||
[PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'
|
||||
IncludeSubKeys = $false
|
||||
CaptureAllValues = $false
|
||||
ValueNames = @('Enabled')
|
||||
}
|
||||
)
|
||||
$errors = New-Object 'System.Collections.Generic.List[string]'
|
||||
$snapshot = [PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'
|
||||
Values = @([PSCustomObject]@{ Name = 'Enabled'; Exists = $true; Kind = 'DWord'; Data = 1 })
|
||||
SubKeys = @()
|
||||
}
|
||||
|
||||
Test-RegistrySnapshotAgainstAllowList -Snapshot $snapshot -PlanMap $planMap -Errors $errors
|
||||
|
||||
$errors | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'rejects corrupt value data even when its path and kind are allowed' {
|
||||
$planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @(
|
||||
[PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'
|
||||
IncludeSubKeys = $false
|
||||
CaptureAllValues = $false
|
||||
ValueNames = @('Bytes')
|
||||
}
|
||||
)
|
||||
$errors = New-Object 'System.Collections.Generic.List[string]'
|
||||
$snapshot = [PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'
|
||||
Values = @([PSCustomObject]@{ Name = 'Bytes'; Exists = $true; Kind = 'Binary'; Data = @(1, 256) })
|
||||
SubKeys = @()
|
||||
}
|
||||
|
||||
Test-RegistrySnapshotAgainstAllowList -Snapshot $snapshot -PlanMap $planMap -Errors $errors
|
||||
|
||||
$errors | Should -HaveCount 1
|
||||
$errors[0] | Should -BeLike "Backup contains invalid registry data for kind 'Binary'*"
|
||||
}
|
||||
|
||||
It 'allows all values in descendants of a recursive plan' {
|
||||
$planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @(
|
||||
[PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'
|
||||
IncludeSubKeys = $true
|
||||
CaptureAllValues = $true
|
||||
ValueNames = @()
|
||||
}
|
||||
)
|
||||
$errors = New-Object 'System.Collections.Generic.List[string]'
|
||||
$snapshot = [PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example\Child'
|
||||
Values = @([PSCustomObject]@{ Name = 'Unlisted'; Exists = $true; Kind = 'String'; Data = 'value' })
|
||||
SubKeys = @()
|
||||
}
|
||||
|
||||
Test-RegistrySnapshotAgainstAllowList -Snapshot $snapshot -PlanMap $planMap -Errors $errors
|
||||
|
||||
$errors | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'reports unexpected paths and values' {
|
||||
$planMap = New-RegistryBackupAllowListPlanMap -CapturePlans @(
|
||||
[PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'
|
||||
IncludeSubKeys = $false
|
||||
CaptureAllValues = $false
|
||||
ValueNames = @('Enabled')
|
||||
}
|
||||
)
|
||||
$errors = New-Object 'System.Collections.Generic.List[string]'
|
||||
$unexpectedPathSnapshot = [PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Unexpected'
|
||||
Values = @()
|
||||
SubKeys = @()
|
||||
}
|
||||
$unexpectedValueSnapshot = [PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'
|
||||
Values = @([PSCustomObject]@{ Name = 'Unexpected'; Exists = $true; Kind = 'String'; Data = 'value' })
|
||||
SubKeys = @()
|
||||
}
|
||||
|
||||
Test-RegistrySnapshotAgainstAllowList -Snapshot $unexpectedPathSnapshot -PlanMap $planMap -Errors $errors
|
||||
Test-RegistrySnapshotAgainstAllowList -Snapshot $unexpectedValueSnapshot -PlanMap $planMap -Errors $errors
|
||||
|
||||
$errors | Should -Contain "Backup contains unexpected registry path 'HKEY_CURRENT_USER\Software\Unexpected' that is not allowed by SelectedFeatures."
|
||||
$errors | Should -Contain "Backup contains unexpected value 'Unexpected' under 'HKEY_CURRENT_USER\Software\Example'."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1')
|
||||
}
|
||||
|
||||
Describe 'Split-RegistryPath' {
|
||||
BeforeEach { $script:RegistryTargetHiveMountName = $null }
|
||||
|
||||
It 'normalizes whitespace, slashes, and a registry subkey' {
|
||||
$result = Split-RegistryPath -path ' HKEY_CURRENT_USER/Software/Example/ '
|
||||
|
||||
$result.Hive | Should -Be 'HKEY_CURRENT_USER'
|
||||
$result.SubKey | Should -Be 'Software\Example'
|
||||
}
|
||||
|
||||
It 'returns null for <Case>' -ForEach @(
|
||||
@{ Case = 'a blank path'; Path = ' ' }
|
||||
@{ Case = 'a path without a hive'; Path = 'Software\Example' }
|
||||
@{ Case = 'an abbreviated hive path'; Path = 'HKCU\Software\Example' }
|
||||
) {
|
||||
Split-RegistryPath -path $Path | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'rewrites the Default user hive only while a target hive is mounted' {
|
||||
$script:RegistryTargetHiveMountName = 'S-1-5-21-123'
|
||||
|
||||
(Split-RegistryPath -path 'HKEY_USERS\Default\Software\Example').SubKey | Should -Be 'S-1-5-21-123\Software\Example'
|
||||
(Split-RegistryPath -path 'HKEY_USERS\Other\Software\Example').SubKey | Should -Be 'Other\Software\Example'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RegistryFilePathForFeature' {
|
||||
BeforeEach { $script:RegfilesPath = 'C:\Regfiles'; $script:Params = @{} }
|
||||
|
||||
It 'uses <Case>' -ForEach @(
|
||||
@{ Case = 'the normal layout by default'; Params = @{}; UseSysprepRegFiles = $false; ExpectedRoot = 'C:\Regfiles' }
|
||||
@{ Case = 'the Sysprep layout for an explicit switch'; Params = @{}; UseSysprepRegFiles = $true; ExpectedRoot = 'C:\Regfiles\Sysprep' }
|
||||
@{ Case = 'the Sysprep layout for User mode'; Params = @{ User = 'Alice' }; UseSysprepRegFiles = $false; ExpectedRoot = 'C:\Regfiles\Sysprep' }
|
||||
) {
|
||||
$script:Params = $Params
|
||||
Get-RegistryFilePathForFeature -RegistryKey 'Feature.reg' -UseSysprepRegFiles:$UseSysprepRegFiles |
|
||||
Should -Be (Join-Path $ExpectedRoot 'Feature.reg')
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-RegistrySubKeyTreeIfExists' {
|
||||
It 'deletes a subtree and ignores a key that disappears during deletion' {
|
||||
$root = [PSCustomObject]@{ Calls = 0 }
|
||||
$root | Add-Member ScriptMethod DeleteSubKeyTree { param($path, $throwOnMissing) $this.Calls++ }
|
||||
{ Remove-RegistrySubKeyTreeIfExists -RootKey $root -SubKeyPath 'Software\Example' } | Should -Not -Throw
|
||||
$root.Calls | Should -Be 1
|
||||
|
||||
$root.PSObject.Members.Remove('DeleteSubKeyTree')
|
||||
$root | Add-Member ScriptMethod DeleteSubKeyTree { throw [System.ArgumentException]::new('already gone') }
|
||||
{ Remove-RegistrySubKeyTreeIfExists -RootKey $root -SubKeyPath 'Software\Example' } | Should -Not -Throw
|
||||
}
|
||||
|
||||
It 'preserves access-denied failures' -ForEach @(
|
||||
@{ Exception = [System.UnauthorizedAccessException]::new('denied') }
|
||||
@{ Exception = [System.Security.SecurityException]::new('blocked') }
|
||||
) {
|
||||
$root = [PSCustomObject]@{ Failure = $Exception }
|
||||
$root | Add-Member ScriptMethod DeleteSubKeyTree { throw $this.Failure }
|
||||
{ Remove-RegistrySubKeyTreeIfExists -RootKey $root -SubKeyPath 'Software\Example' } | Should -Throw
|
||||
}
|
||||
|
||||
It 'preserves unexpected registry deletion failures' {
|
||||
$root = [PSCustomObject]@{ Failure = [System.IO.IOException]::new('registry I/O failure') }
|
||||
$root | Add-Member ScriptMethod DeleteSubKeyTree { throw $this.Failure }
|
||||
|
||||
{ Remove-RegistrySubKeyTreeIfExists -RootKey $root -SubKeyPath 'Software\Example' } | Should -Throw '*registry I/O failure*'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Backup-RegistrySnapshotCapture.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryApplyState.ps1')
|
||||
|
||||
function New-FakeRegistryKey {
|
||||
param(
|
||||
[string]$Name = 'HKEY_CURRENT_USER\Software\Example',
|
||||
[hashtable]$Values = @{},
|
||||
[hashtable]$Kinds = @{},
|
||||
[hashtable]$Children = @{}
|
||||
)
|
||||
|
||||
$key = [PSCustomObject]@{
|
||||
Name = $Name
|
||||
Values = $Values
|
||||
Kinds = $Kinds
|
||||
Children = $Children
|
||||
Closed = $false
|
||||
DeletedValues = [System.Collections.Generic.List[string]]::new()
|
||||
SetCalls = [System.Collections.Generic.List[object]]::new()
|
||||
}
|
||||
$key | Add-Member ScriptMethod GetValueNames { @($this.Kinds.Keys) }
|
||||
$key | Add-Member ScriptMethod GetValueKind { param($valueName) $this.Kinds[$valueName] }
|
||||
$key | Add-Member ScriptMethod GetValue { param($valueName, $defaultValue, $options) $this.Values[$valueName] }
|
||||
$key | Add-Member ScriptMethod GetSubKeyNames { @($this.Children.Keys) }
|
||||
$key | Add-Member ScriptMethod OpenSubKey { param($subKeyName, $writable) $this.Children[$subKeyName] }
|
||||
$key | Add-Member ScriptMethod Close { $this.Closed = $true }
|
||||
$key | Add-Member ScriptMethod DeleteValue { param($valueName, $throwOnMissing) $this.DeletedValues.Add([string]$valueName) }
|
||||
$key | Add-Member ScriptMethod SetValue { param($valueName, $data, $kind) $this.SetCalls.Add(@($valueName, $data, $kind)) }
|
||||
return $key
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Convert-RegistryValueToSnapshot' {
|
||||
It 'normalizes <Case> without expanding registry strings' -ForEach @(
|
||||
@{ Case = 'null binary'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = $null; Expected = @() }
|
||||
@{ Case = 'empty binary'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = [byte[]]::new(0); Expected = @() }
|
||||
@{ Case = 'binary'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = [byte[]](1, 255); Expected = @(1, 255) }
|
||||
@{ Case = 'multi-string'; Kind = [Microsoft.Win32.RegistryValueKind]::MultiString; Data = [string[]]@('one', 'two'); Expected = @('one', 'two') }
|
||||
@{ Case = 'unsigned DWord'; Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Data = -1; Expected = [uint32]::MaxValue }
|
||||
@{ Case = 'unsigned QWord'; Kind = [Microsoft.Win32.RegistryValueKind]::QWord; Data = -1L; Expected = [uint64]::MaxValue }
|
||||
@{ Case = 'expandable string'; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; Data = '%TEMP%'; Expected = '%TEMP%' }
|
||||
) {
|
||||
$key = New-FakeRegistryKey -Values @{ Value = $Data } -Kinds @{ Value = $Kind }
|
||||
$snapshot = Convert-RegistryValueToSnapshot -RegistryKey $key -ValueName 'Value'
|
||||
$snapshot.Exists | Should -BeTrue
|
||||
$snapshot.Kind | Should -Be $Kind.ToString()
|
||||
if ($Kind -eq [Microsoft.Win32.RegistryValueKind]::Binary) {
|
||||
$snapshot.Data -is [array] | Should -BeTrue
|
||||
$snapshot.Data.Count | Should -Be $Expected.Count
|
||||
if ($Expected.Count -gt 0) {
|
||||
$snapshot.Data | Should -Be $Expected
|
||||
}
|
||||
return
|
||||
}
|
||||
$snapshot.Data | Should -Be $Expected
|
||||
}
|
||||
|
||||
It 'rejects REG_NONE values because their data cannot be read reliably through .NET' {
|
||||
$key = New-FakeRegistryKey -Values @{ Value = [byte[]](1) } -Kinds @{ Value = [Microsoft.Win32.RegistryValueKind]::None }
|
||||
|
||||
{ Convert-RegistryValueToSnapshot -RegistryKey $key -ValueName 'Value' } |
|
||||
Should -Throw 'REG_NONE registry values are not supported for backup*'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Registry value backup round trip' {
|
||||
BeforeAll {
|
||||
$script:RoundTripRegistrySubKey = "Software\Win11Debloat\Tests\RegistrySnapshotRoundTrip-$([guid]::NewGuid().ToString('N'))"
|
||||
$script:RoundTripRegistryKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($script:RoundTripRegistrySubKey)
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
if ($script:RoundTripRegistryKey) {
|
||||
$script:RoundTripRegistryKey.Close()
|
||||
}
|
||||
[Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($script:RoundTripRegistrySubKey, $false)
|
||||
}
|
||||
|
||||
It 'captures, serializes, and restores <Case> values' -ForEach @(
|
||||
@{ Case = 'String'; Kind = [Microsoft.Win32.RegistryValueKind]::String; Data = 'value' }
|
||||
@{ Case = 'ExpandString'; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; Data = '%TEMP%\value' }
|
||||
@{ Case = 'Binary'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = [byte[]](1) }
|
||||
@{ Case = 'DWord'; Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Data = [int]42 }
|
||||
@{ Case = 'MultiString'; Kind = [Microsoft.Win32.RegistryValueKind]::MultiString; Data = [string[]]@('one', 'two') }
|
||||
@{ Case = 'QWord'; Kind = [Microsoft.Win32.RegistryValueKind]::QWord; Data = [int64]42 }
|
||||
) {
|
||||
$sourceName = "Source-$Case"
|
||||
$restoredName = "Restored-$Case"
|
||||
$script:RoundTripRegistryKey.SetValue($sourceName, $Data, $Kind)
|
||||
|
||||
$snapshot = Convert-RegistryValueToSnapshot -RegistryKey $script:RoundTripRegistryKey -ValueName $sourceName
|
||||
$serializedSnapshot = $snapshot | ConvertTo-Json -Depth 5 | ConvertFrom-Json
|
||||
$serializedSnapshot.Name = $restoredName
|
||||
|
||||
Restore-RegistryValueSnapshot -RegistryKey $script:RoundTripRegistryKey -Snapshot $serializedSnapshot
|
||||
$restoredSnapshot = Convert-RegistryValueToSnapshot -RegistryKey $script:RoundTripRegistryKey -ValueName $restoredName
|
||||
|
||||
$restoredSnapshot.Kind | Should -Be $snapshot.Kind
|
||||
if ($Kind -in @([Microsoft.Win32.RegistryValueKind]::Binary, [Microsoft.Win32.RegistryValueKind]::None, [Microsoft.Win32.RegistryValueKind]::MultiString)) {
|
||||
$restoredSnapshot.Data -is [array] | Should -BeTrue
|
||||
}
|
||||
$restoredSnapshot.Data | Should -Be $snapshot.Data
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Convert-RegistryKeyToSnapshot' {
|
||||
It 'captures selected missing values and recursively closes child keys' {
|
||||
$child = New-FakeRegistryKey -Name 'HKEY_CURRENT_USER\Software\Example\Child' -Values @{ ChildValue = 'data' } -Kinds @{ ChildValue = [Microsoft.Win32.RegistryValueKind]::String }
|
||||
$root = New-FakeRegistryKey -Values @{ Present = 1 } -Kinds @{ Present = [Microsoft.Win32.RegistryValueKind]::DWord } -Children @{ Child = $child }
|
||||
|
||||
$snapshot = Convert-RegistryKeyToSnapshot -RegistryKey $root -FullPath $root.Name -ValueNames @('Missing', 'Present') -IncludeSubKeys:$true
|
||||
|
||||
$snapshot.Values | Should -HaveCount 2
|
||||
($snapshot.Values | Where-Object Name -eq 'Missing').Exists | Should -BeFalse
|
||||
$snapshot.SubKeys | Should -HaveCount 1
|
||||
$snapshot.SubKeys[0].Values[0].Data | Should -Be 'data'
|
||||
$child.Closed | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RegistryKeySnapshot' {
|
||||
BeforeEach {
|
||||
Mock Split-RegistryPath { [PSCustomObject]@{ Hive = 'HKEY_CURRENT_USER'; SubKey = 'Software\Example' } }
|
||||
}
|
||||
|
||||
It 'returns an explicit non-existent snapshot when the key is absent' {
|
||||
$root = [PSCustomObject]@{}
|
||||
$root | Add-Member ScriptMethod OpenSubKey { $null }
|
||||
Mock Get-RegistryRootKey { $root }
|
||||
|
||||
$snapshot = Get-RegistryKeySnapshot -KeyPath 'HKEY_CURRENT_USER\Software\Example'
|
||||
$snapshot.Exists | Should -BeFalse
|
||||
$snapshot.Values | Should -HaveCount 0
|
||||
}
|
||||
|
||||
It 'closes an opened root snapshot key' {
|
||||
$key = New-FakeRegistryKey
|
||||
$root = [PSCustomObject]@{ Key = $key }
|
||||
$root | Add-Member ScriptMethod OpenSubKey { $this.Key }
|
||||
Mock Get-RegistryRootKey { $root }
|
||||
|
||||
Get-RegistryKeySnapshot -KeyPath 'HKEY_CURRENT_USER\Software\Example' | Out-Null
|
||||
$key.Closed | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Restore-RegistryValueSnapshot' {
|
||||
It 'deletes a missing value using the default-value name when necessary' {
|
||||
$key = New-FakeRegistryKey
|
||||
Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot ([PSCustomObject]@{ Name = $null; Exists = $false })
|
||||
$key.DeletedValues | Should -Be @('')
|
||||
}
|
||||
|
||||
It 'sets a value with its original kind and converted bit pattern' {
|
||||
$key = New-FakeRegistryKey
|
||||
Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot ([PSCustomObject]@{ Name = 'Large'; Exists = $true; Kind = 'QWord'; Data = [uint64]::MaxValue })
|
||||
$key.SetCalls | Should -HaveCount 1
|
||||
$key.SetCalls[0][0] | Should -Be 'Large'
|
||||
$key.SetCalls[0][1] | Should -Be ([int64]-1)
|
||||
$key.SetCalls[0][1].GetType() | Should -Be ([int64])
|
||||
$key.SetCalls[0][2] | Should -Be ([Microsoft.Win32.RegistryValueKind]::QWord)
|
||||
}
|
||||
|
||||
It 'does not silently downgrade a failed value write to Binary' {
|
||||
$key = New-FakeRegistryKey
|
||||
$key.PSObject.Members.Remove('SetValue')
|
||||
$key | Add-Member ScriptMethod SetValue { throw 'write denied' }
|
||||
{ Restore-RegistryValueSnapshot -RegistryKey $key -Snapshot ([PSCustomObject]@{ Name = 'Value'; Exists = $true; Kind = 'String'; Data = 'data' }) } |
|
||||
Should -Throw '*write denied*'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Restore-RegistryKeySnapshot - mutation flow' {
|
||||
BeforeEach {
|
||||
Mock Split-RegistryPath {
|
||||
param($path)
|
||||
[PSCustomObject]@{ Hive = 'HKEY_CURRENT_USER'; SubKey = $path.Substring('HKEY_CURRENT_USER\'.Length) }
|
||||
}
|
||||
Mock Remove-RegistrySubKeyTreeIfExists {}
|
||||
}
|
||||
|
||||
It 'removes a key that did not exist when the backup was created' {
|
||||
Mock Get-RegistryRootKey { [PSCustomObject]@{} }
|
||||
Restore-RegistryKeySnapshot -Snapshot ([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Gone'; Exists = $false; Values = @(); SubKeys = @() })
|
||||
Should -Invoke Remove-RegistrySubKeyTreeIfExists -Times 1 -Exactly -ParameterFilter { $SubKeyPath -eq 'Software\Gone' }
|
||||
}
|
||||
|
||||
It 'creates, populates, and closes an existing key snapshot' {
|
||||
$key = New-FakeRegistryKey
|
||||
$root = [PSCustomObject]@{ Key = $key }
|
||||
$root | Add-Member ScriptMethod CreateSubKey { param($path) $this.Key }
|
||||
Mock Get-RegistryRootKey { $root }
|
||||
|
||||
Restore-RegistryKeySnapshot -Snapshot ([PSCustomObject]@{
|
||||
Path = 'HKEY_CURRENT_USER\Software\Example'; Exists = $true
|
||||
Values = @([PSCustomObject]@{ Name = 'Enabled'; Exists = $true; Kind = 'DWord'; Data = 1 })
|
||||
SubKeys = @()
|
||||
})
|
||||
|
||||
$key.SetCalls | Should -HaveCount 1
|
||||
$key.Closed | Should -BeTrue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
BeforeAll {
|
||||
function Get-TargetUserForAppRemoval { 'AllUsers' }
|
||||
function Get-WingetInstalledApps { param($TimeOut, [switch]$NonBlocking) @() }
|
||||
function Test-AppInWingetList { param($appId, $InstalledList) $false }
|
||||
function Invoke-NonBlocking { param($ScriptBlock, $ArgumentList) }
|
||||
function Get-UserName { 'Alice' }
|
||||
function Invoke-ForceRemoveEdge {}
|
||||
function Show-MessageBox { 'No' }
|
||||
function Invoke-WithTargetUserHive { param($TargetUserName, $ScriptBlock, $ArgumentObject) }
|
||||
function Invoke-RegistryOperation { param($Operation, $RegFilePath) }
|
||||
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Remove-SelectedApps.ps1')
|
||||
}
|
||||
|
||||
Describe 'Remove-SelectedApps' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:CancelRequested = $false
|
||||
$script:ApplySubStepCallback = $null
|
||||
$script:WingetInstalled = $true
|
||||
Mock Get-TargetUserForAppRemoval { 'AllUsers' }
|
||||
Mock Get-AppRemovalMethod { 'Appx' }
|
||||
Mock Remove-WinGetApp {}
|
||||
Mock Remove-AppxApp {}
|
||||
Mock Test-AppStillInstalled { $false }
|
||||
Mock Get-WingetInstalledApps { @() }
|
||||
Mock Request-EdgeForceRemove {}
|
||||
Mock Write-Host {}
|
||||
}
|
||||
|
||||
It 'honors WhatIf without invoking either removal backend' {
|
||||
$script:Params = @{ WhatIf = $true }
|
||||
Remove-SelectedApps -appsList @('One.App', 'Two.App')
|
||||
Should -Invoke Remove-WinGetApp -Times 0 -Exactly
|
||||
Should -Invoke Remove-AppxApp -Times 0 -Exactly
|
||||
Should -Invoke Write-Host -Times 2 -Exactly -ParameterFilter { $Object -like '*WhatIf*Remove App*' }
|
||||
}
|
||||
|
||||
It 'dispatches each app to its configured backend and target scope' {
|
||||
Mock Get-AppRemovalMethod { param($appId) if ($appId -eq 'Winget.App') { 'WinGet' } else { 'Appx' } }
|
||||
Remove-SelectedApps -appsList @('Winget.App', 'Appx.App')
|
||||
Should -Invoke Remove-WinGetApp -Times 1 -Exactly -ParameterFilter { $app -eq 'Winget.App' }
|
||||
Should -Invoke Remove-AppxApp -Times 1 -Exactly -ParameterFilter { $app -eq 'Appx.App' -and $targetUser -eq 'AllUsers' }
|
||||
}
|
||||
|
||||
It 'stops before the first removal when cancellation is requested' {
|
||||
$script:CancelRequested = $true
|
||||
Remove-SelectedApps -appsList @('One.App')
|
||||
Should -Invoke Remove-WinGetApp -Times 0 -Exactly
|
||||
Should -Invoke Remove-AppxApp -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'prompts for forced Edge removal at most once after failed winget removals' {
|
||||
Mock Get-AppRemovalMethod { 'WinGet' }
|
||||
Mock Test-AppStillInstalled { $true }
|
||||
Remove-SelectedApps -appsList @('Microsoft.Edge', 'XPFFTQ037JWMHS')
|
||||
Should -Invoke Request-EdgeForceRemove -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-AppRemovalMethod' {
|
||||
BeforeEach {
|
||||
$script:AppRemovalMethodCache = $null
|
||||
$script:AppsListFilePath = Join-Path $TestDrive 'Apps.json'
|
||||
}
|
||||
|
||||
It 'caches aliases and skips malformed IDs' {
|
||||
'{"Apps":[{"AppId":[" One.App ","Alias.App"],"RemovalMethod":"WinGet"},{"AppId":null},{"AppId":42},{"AppId":"Two.App"}]}' |
|
||||
Set-Content -LiteralPath $script:AppsListFilePath -Encoding UTF8
|
||||
|
||||
Get-AppRemovalMethod -appId 'One.App' | Should -Be 'WinGet'
|
||||
Get-AppRemovalMethod -appId 'Alias.App' | Should -Be 'WinGet'
|
||||
Get-AppRemovalMethod -appId 'Two.App' | Should -Be 'Appx'
|
||||
Get-AppRemovalMethod -appId 'Unknown.App' | Should -Be 'Appx'
|
||||
}
|
||||
|
||||
It 'warns and defaults to Appx when the catalog is malformed' {
|
||||
'not json' | Set-Content -LiteralPath $script:AppsListFilePath
|
||||
Mock Write-Warning {}
|
||||
Get-AppRemovalMethod -appId 'Unknown.App' | Should -Be 'Appx'
|
||||
Should -Invoke Write-Warning -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-WinGetApp' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:WingetInstalled = $true
|
||||
Mock Invoke-NonBlocking {}
|
||||
Mock Set-RunOnceWingetTask {}
|
||||
Mock Get-UserName { 'Alice' }
|
||||
Mock Write-Host {}
|
||||
}
|
||||
|
||||
It 'reports unavailable winget without invoking or scheduling removal' {
|
||||
$script:WingetInstalled = $false
|
||||
Remove-WinGetApp -app 'One.App'
|
||||
Should -Invoke Invoke-NonBlocking -Times 0 -Exactly
|
||||
Should -Invoke Set-RunOnceWingetTask -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'schedules removal only for explicit user or Sysprep targets' -ForEach @(
|
||||
@{ Params = @{}; Scheduled = 0 }
|
||||
@{ Params = @{ User = 'Alice' }; Scheduled = 1 }
|
||||
@{ Params = @{ Sysprep = $true }; Scheduled = 1 }
|
||||
) {
|
||||
$script:Params = $Params
|
||||
Remove-WinGetApp -app 'One.App'
|
||||
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter { $ArgumentList -eq 'One.App' }
|
||||
Should -Invoke Set-RunOnceWingetTask -Times $Scheduled -Exactly -ParameterFilter { $appId -eq 'One.App' }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-AppxApp' {
|
||||
BeforeEach { Mock Invoke-NonBlocking {} }
|
||||
|
||||
It 'passes the wildcard and target user data for <Target>' -ForEach @(
|
||||
@{ Target = 'AllUsers'; ExpectedArguments = 1 }
|
||||
@{ Target = 'CurrentUser'; ExpectedArguments = 1 }
|
||||
@{ Target = 'Alice'; ExpectedArguments = 2 }
|
||||
) {
|
||||
Remove-AppxApp -app 'One.App' -targetUser $Target
|
||||
Should -Invoke Invoke-NonBlocking -Times 1 -Exactly -ParameterFilter {
|
||||
@($ArgumentList).Count -eq $ExpectedArguments -and @($ArgumentList)[0] -eq '*One.App*' -and
|
||||
($ExpectedArguments -eq 1 -or @($ArgumentList)[1] -eq 'Alice')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-AppStillInstalled' {
|
||||
BeforeEach {
|
||||
$script:WingetInstalled = $true
|
||||
Mock Get-AppxPackage { $null }
|
||||
Mock Test-AppInWingetList { $false }
|
||||
Mock Get-WingetInstalledApps { @() }
|
||||
Mock Write-Warning {}
|
||||
}
|
||||
|
||||
It 'prefers all-user Appx detection and avoids winget lookup' {
|
||||
Mock Get-AppxPackage { [PSCustomObject]@{ Name = 'One.App' } }
|
||||
Test-AppStillInstalled -appId 'One.App' | Should -BeTrue
|
||||
Should -Invoke Get-AppxPackage -Times 1 -Exactly -ParameterFilter { $AllUsers }
|
||||
Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'uses a supplied winget list without launching a live query' {
|
||||
Mock Test-AppInWingetList { $true }
|
||||
Test-AppStillInstalled -appId 'One.App' -InstalledList @([PSCustomObject]@{ Id = 'One.App' }) | Should -BeTrue
|
||||
Should -Invoke Get-WingetInstalledApps -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'warns when a non-Appx app cannot be verified without winget' {
|
||||
$script:WingetInstalled = $false
|
||||
Test-AppStillInstalled -appId 'One.App' | Should -BeFalse
|
||||
Should -Invoke Write-Warning -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Set-RunOnceWingetTask' {
|
||||
BeforeEach {
|
||||
$script:Params = @{ User = 'Alice' }
|
||||
Mock Invoke-WithTargetUserHive {
|
||||
param($TargetUserName, $ScriptBlock, $ArgumentObject)
|
||||
& $ScriptBlock $ArgumentObject
|
||||
}
|
||||
$script:runOnceOperation = $null
|
||||
Mock Invoke-RegistryOperation {
|
||||
param($Operation)
|
||||
$script:runOnceOperation = $Operation
|
||||
}
|
||||
}
|
||||
|
||||
It 'encodes shell metacharacters and writes a safe RunOnce operation' {
|
||||
Set-RunOnceWingetTask -appId "Vendor.App&'Test"
|
||||
Should -Invoke Invoke-WithTargetUserHive -Times 1 -Exactly -ParameterFilter { $TargetUserName -eq 'Alice' }
|
||||
Should -Invoke Invoke-RegistryOperation -Times 1 -Exactly -ParameterFilter {
|
||||
$Operation.ValueName -eq "Uninstall_Vendor.App&'Test" -and
|
||||
$RegFilePath -eq '<dynamic>'
|
||||
}
|
||||
|
||||
$script:runOnceOperation.ValueData | Should -Match '^powershell\.exe -NoProfile -EncodedCommand [A-Za-z0-9+/=]+$'
|
||||
$encodedCommand = $script:runOnceOperation.ValueData -replace '^powershell\.exe -NoProfile -EncodedCommand ', ''
|
||||
$decodedCommand = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedCommand))
|
||||
$decodedCommand | Should -Be "winget uninstall --accept-source-agreements --disable-interactivity --id 'Vendor.App&''Test'"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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' {
|
||||
It 'extracts a user name from a start-menu path and falls back for unknown paths' {
|
||||
Get-StartMenuUserNameFromPath -StartMenuBinFile 'C:\Users\Alice\AppData\Local\Packages\Start\start2.bin' | Should -Be 'Alice'
|
||||
Get-StartMenuUserNameFromPath -StartMenuBinFile 'C:\Temp\start2.bin' | Should -Be 'unknown'
|
||||
}
|
||||
|
||||
It 'returns the latest current-user start-menu backup' {
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[PSCustomObject]@{ Name = 'Win11Debloat-StartBackup-20260101_120000.bak'; FullName = 'C:\Backups\older.bak' }
|
||||
[PSCustomObject]@{ Name = 'Win11Debloat-StartBackup-20260102_120000.bak'; FullName = 'C:\Backups\newer.bak' }
|
||||
)
|
||||
}
|
||||
|
||||
Get-StartMenuBackupPath -Scope CurrentUser | Should -Be 'C:\Backups\newer.bak'
|
||||
}
|
||||
|
||||
It 'returns the first available all-users start-menu backup' {
|
||||
$script:allUsersPath = 'C:\Users\*\AppData\Local\Packages\Start\LocalState'
|
||||
Mock Get-UserDirectory { $script:allUsersPath }
|
||||
Mock Get-ChildItem {
|
||||
param($Path)
|
||||
if ($Path -eq $script:allUsersPath) {
|
||||
return [PSCustomObject]@{ FullName = 'C:\Users\Alice\AppData\Local\Packages\Start\LocalState' }
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{ Name = 'Win11Debloat-StartBackup-20260103_120000.bak'; FullName = 'C:\Users\Alice\backup.bak' }
|
||||
}
|
||||
|
||||
Get-StartMenuBackupPath -Scope AllUsers | Should -Be 'C:\Users\Alice\backup.bak'
|
||||
}
|
||||
|
||||
It 'restores a start menu backup and preserves the replaced file' {
|
||||
$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'
|
||||
|
||||
$result = Restore-StartMenuFromBackup -StartMenuBinFile $startMenuFile -BackupFilePath $backupFile
|
||||
|
||||
$result.Result | Should -BeTrue
|
||||
Get-Content -LiteralPath $startMenuFile -Raw | Should -Match 'backup'
|
||||
@(Get-ChildItem -LiteralPath $TestDrive -Filter 'Win11Debloat-StartRestore-*.bak').Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'reports a missing backup without changing the start-menu file' {
|
||||
$script:Params = @{}
|
||||
$startMenuFile = Join-Path $TestDrive 'start2.bin'
|
||||
Set-Content -LiteralPath $startMenuFile -Value 'current'
|
||||
|
||||
$result = Restore-StartMenuFromBackup -StartMenuBinFile $startMenuFile -BackupFilePath (Join-Path $TestDrive 'missing.bak')
|
||||
|
||||
$result.Result | Should -BeFalse
|
||||
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 } }
|
||||
|
||||
Restore-StartMenu -BackupFilePath 'C:\Backups\backup.bak' | Should -Not -BeNullOrEmpty
|
||||
Should -Invoke Restore-StartMenuFromBackup -Times 1 -Exactly -ParameterFilter { $BackupFilePath -eq 'C:\Backups\backup.bak' }
|
||||
}
|
||||
|
||||
It 'restores every discovered user and removes the default-profile start menu' {
|
||||
$script:Params = @{}
|
||||
$script:allUsersStartPath = 'C:\Users\*\LocalState'
|
||||
$script:defaultStartPath = Join-Path $TestDrive 'DefaultLocalState'
|
||||
$defaultBin = Join-Path $script:defaultStartPath 'start2.bin'
|
||||
New-Item -ItemType Directory -Path $script:defaultStartPath | Out-Null
|
||||
Set-Content -LiteralPath $defaultBin -Value 'template'
|
||||
Mock Get-UserDirectory { param($userName) if ($userName -eq '*') { $script:allUsersStartPath } else { $script:defaultStartPath } }
|
||||
Mock Get-ChildItem { param($Path) if ($Path -eq $script:allUsersStartPath) { [PSCustomObject]@{ FullName = 'C:\Users\Alice\LocalState' } } }
|
||||
Mock Restore-StartMenuFromBackup { [PSCustomObject]@{ UserName = 'Alice'; Result = $true; Message = 'Restored' } }
|
||||
|
||||
$result = @(Restore-StartMenuForAllUsers -BackupFilePath 'C:\Backups\backup.bak')
|
||||
|
||||
$result.Count | Should -Be 2
|
||||
Should -Invoke Restore-StartMenuFromBackup -Times 1 -Exactly
|
||||
Test-Path -LiteralPath $defaultBin | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Resolve-UserProfilePath.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Test-UserProfileExists.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-UserDirectory.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Test-ModernStandbySupport.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Invoke-RestartExplorer.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Threading\Invoke-DoEvents.ps1')
|
||||
|
||||
function powercfg { $script:PowerCfgOutput }
|
||||
function Wait-ForKeyPress {}
|
||||
function Get-RebootFeatureLabels { @() }
|
||||
}
|
||||
|
||||
Describe 'Test-UserProfileExists' {
|
||||
BeforeEach {
|
||||
Mock Resolve-UserProfileContext {
|
||||
[PSCustomObject]@{ ProfilePath = 'C:\Users\Alice'; UserSid = 'S-1-5-21-1000' }
|
||||
}
|
||||
Mock Wait-ForKeyPress {}
|
||||
Mock Write-Error {}
|
||||
}
|
||||
|
||||
It 'rejects blank and path-unsafe user names before resolving a profile' {
|
||||
Test-UserProfileExists -userName '' | Should -BeFalse
|
||||
Test-UserProfileExists -userName 'Alice[1]' | Should -BeFalse
|
||||
Should -Invoke Resolve-UserProfileContext -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'accepts a resolved ordinary user and the Default profile without a SID' {
|
||||
Test-UserProfileExists -userName ' Alice ' | Should -BeTrue
|
||||
Mock Resolve-UserProfileContext { [PSCustomObject]@{ ProfilePath = 'C:\Users\Default'; UserSid = $null } }
|
||||
Test-UserProfileExists -userName 'Default' | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'returns a resolved profile path and optionally appends a file name' {
|
||||
Mock Test-Path { $true }
|
||||
|
||||
Get-UserDirectory -userName 'Alice' | Should -Be 'C:\Users\Alice'
|
||||
Get-UserDirectory -userName 'Alice' -fileName 'AppData\Local' | Should -Be 'C:\Users\Alice\AppData\Local'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-ModernStandbySupport' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
Mock Write-Host {}
|
||||
}
|
||||
|
||||
It 'detects S0 Modern Standby from powercfg output' {
|
||||
$script:PowerCfgOutput = @('The following sleep states are available on this system:', ' Standby (S0 Low Power Idle) Network Connected')
|
||||
|
||||
Test-ModernStandbySupport | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not restart Explorer for WhatIf or an explicit skip' {
|
||||
Mock Stop-Process {}
|
||||
$script:Params = @{ WhatIf = $true }
|
||||
Invoke-RestartExplorer
|
||||
$script:Params = @{ NoRestartExplorer = $true }
|
||||
Invoke-RestartExplorer
|
||||
|
||||
Should -Invoke Stop-Process -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'restarts Explorer when allowed and reports reboot-required features' {
|
||||
Mock Stop-Process {}
|
||||
Mock Get-RebootFeatureLabels { @('Disable telemetry') }
|
||||
|
||||
Invoke-RestartExplorer
|
||||
|
||||
Should -Invoke Stop-Process -Times 1 -Exactly -ParameterFilter { $processName -eq 'Explorer' -and $Force }
|
||||
Should -Invoke Get-RebootFeatureLabels -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Resolve-UserSid' {
|
||||
BeforeEach {
|
||||
$script:ResolvedUserSidCache = @{}
|
||||
$script:MachineDomainJoinStateKnown = $null
|
||||
$script:MachineIsDomainJoined = $false
|
||||
$script:MachineNetBiosDomain = ''
|
||||
}
|
||||
|
||||
It 'builds domain-aware candidate forms and compares profile folder leaves' {
|
||||
Mock Get-ProfileFolderDomainSuffix { 'CONTOSO' }
|
||||
|
||||
Get-UserNameMatchCandidates -Value 'CONTOSO\Alice' | Should -Be @('CONTOSO\Alice', 'Alice', 'Alice.CONTOSO')
|
||||
Test-UserNameMatchesProfileLeaf -UserName 'CONTOSO\Alice' -ProfileLeaf 'Alice.CONTOSO' | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'prefers the NetBIOS domain source and falls back to the DNS label' {
|
||||
Mock Get-CimInstance { [PSCustomObject]@{ DomainName = 'CONTOSO'; DomainControllerName = '\\dc01.contoso.com' } }
|
||||
Resolve-NetBiosDomainName -RawDomain 'contoso.com' | Should -Be 'CONTOSO'
|
||||
|
||||
Mock Get-CimInstance { throw 'CIM unavailable' }
|
||||
Resolve-NetBiosDomainName -RawDomain 'contoso.com' | Should -Be 'contoso'
|
||||
}
|
||||
|
||||
It 'stores and retrieves resolved SIDs through normalized cache keys' {
|
||||
Set-ResolvedUserSidCache -Candidates @('Alice', 'CONTOSO\Alice') -Sid 'S-1-5-21-1000'
|
||||
|
||||
Get-CachedResolvedUserSid -Candidates @('contoso\alice') | Should -Be 'S-1-5-21-1000'
|
||||
}
|
||||
|
||||
It 'returns a local-user SID before falling back to CIM' {
|
||||
Mock Get-Command { [PSCustomObject]@{ Name = 'Get-LocalUser' } } -ParameterFilter { $Name -eq 'Get-LocalUser' }
|
||||
Mock Get-LocalUser { [PSCustomObject]@{ SID = [PSCustomObject]@{ Value = 'S-1-5-21-1000' } } }
|
||||
Mock Get-CimInstance { throw 'CIM should not be queried' }
|
||||
|
||||
Try-ResolveSidByLocalLookup -Candidates @('Alice') | Should -Be 'S-1-5-21-1000'
|
||||
Should -Invoke Get-CimInstance -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'recovers a workgroup SID from a matching ProfileList folder' {
|
||||
Mock Test-MachineIsDomainJoined { $false }
|
||||
Mock Get-ChildItem { [PSCustomObject]@{ PSPath = 'Registry::ProfileList\S-1-5-21-1000'; PSChildName = 'S-1-5-21-1000' } }
|
||||
Mock Get-ItemPropertyValue { 'C:\Users\Alice' }
|
||||
|
||||
Try-ResolveSidFromProfileList -Candidates @('Alice') | Should -Be 'S-1-5-21-1000'
|
||||
}
|
||||
|
||||
It 'constructs user contexts and resolves a workgroup SID through NTAccount' {
|
||||
$context = New-ResolvedUserContext -UserName 'Alice' -UserSid 'S-1-5-21-1000' -ProfilePath 'C:\Users\Alice'
|
||||
$context.UserName | Should -Be 'Alice'
|
||||
$context.UserSid | Should -Be 'S-1-5-21-1000'
|
||||
|
||||
Mock Get-CachedResolvedUserSid { $null }
|
||||
Mock Test-MachineIsDomainJoined { $false }
|
||||
Mock Try-ResolveSidByNtAccount { 'S-1-5-21-1000' }
|
||||
Mock Set-ResolvedUserSidCache {}
|
||||
|
||||
Resolve-UserSid -UserName 'Alice' | Should -Be 'S-1-5-21-1000'
|
||||
Should -Invoke Try-ResolveSidByNtAccount -Times 1 -Exactly -ParameterFilter { $UserName -eq 'Alice' }
|
||||
}
|
||||
|
||||
It 'does not qualify blank names or process UI events without a GUI window' {
|
||||
Get-QualifiedProcessIdentityName -Candidate '' | Should -BeNullOrEmpty
|
||||
$script:GuiWindow = $null
|
||||
{ Invoke-DoEvents } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\GUI\Restore-BackupDialogFeatureLists.ps1')
|
||||
}
|
||||
|
||||
Describe 'New-RestoreDialogState' {
|
||||
BeforeEach {
|
||||
$script:Features = @{
|
||||
RegistryFeature = [PSCustomObject]@{ Label = 'Registry feature'; Category = 'Privacy'; RegistryKey = 'RegistryFeature.reg' }
|
||||
CustomFeature = [PSCustomObject]@{ Label = 'Custom feature'; Category = 'Privacy'; RegistryKey = '' }
|
||||
HiddenFeature = [PSCustomObject]@{ Label = 'Hidden feature'; Category = ''; RegistryKey = 'HiddenFeature.reg' }
|
||||
}
|
||||
}
|
||||
|
||||
It 'creates a dialog state with the supplied values' {
|
||||
$backup = [PSCustomObject]@{ SelectedFeatures = @('RegistryFeature') }
|
||||
|
||||
$state = New-RestoreDialogState -Result 'OK' -SelectedFile 'C:\Backups\backup.json' -Backup $backup
|
||||
|
||||
$state.Result | Should -Be 'OK'
|
||||
$state.SelectedFile | Should -Be 'C:\Backups\backup.json'
|
||||
$state.Backup | Should -Be $backup
|
||||
}
|
||||
|
||||
It 'looks up feature definitions and resolves display labels with fallbacks' {
|
||||
Get-RestoreDialogFeatureDefinition -FeatureId 'RegistryFeature' -Features $script:Features | Should -Be $script:Features.RegistryFeature
|
||||
Get-RestoreDialogFeatureDefinition -FeatureId 'MissingFeature' -Features $script:Features | Should -BeNullOrEmpty
|
||||
Get-RestoreDialogFeatureDisplayLabel -FeatureId 'RegistryFeature' -Features $script:Features | Should -Be 'Registry feature'
|
||||
Get-RestoreDialogFeatureDisplayLabel -FeatureId 'MissingFeature' -Features $script:Features | Should -Be 'MissingFeature'
|
||||
Get-RestoreDialogFeatureDisplayLabel -FeatureId '' -Features $script:Features | Should -Be 'Unknown feature'
|
||||
}
|
||||
|
||||
It 'identifies visible, automatically revertible features' {
|
||||
Test-RestoreDialogFeatureCanAutoRevert -FeatureId 'RegistryFeature' -Features $script:Features | Should -BeTrue
|
||||
Test-RestoreDialogFeatureCanAutoRevert -FeatureId 'CustomFeature' -Features $script:Features | Should -BeFalse
|
||||
Test-RestoreDialogFeatureVisibleInOverview -FeatureId 'RegistryFeature' -Features $script:Features | Should -BeTrue
|
||||
Test-RestoreDialogFeatureVisibleInOverview -FeatureId 'HiddenFeature' -Features $script:Features | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'deduplicates and combines forward and undo feature IDs' {
|
||||
$backup = [PSCustomObject]@{
|
||||
SelectedFeatures = @('RegistryFeature', 'registryfeature', '', 'CustomFeature')
|
||||
SelectedUndoFeatures = @('CustomFeature', 'HiddenFeature', 'hiddenfeature')
|
||||
}
|
||||
|
||||
Get-SelectedForwardFeatureIdsFromBackup -SelectedBackup $backup | Should -Be @('RegistryFeature', 'CustomFeature')
|
||||
Get-SelectedUndoFeatureIdsFromBackup -SelectedBackup $backup | Should -Be @('CustomFeature', 'HiddenFeature')
|
||||
Get-CombinedSelectedFeatureIdsFromBackup -SelectedBackup $backup | Should -Be @('RegistryFeature', 'CustomFeature', 'HiddenFeature')
|
||||
Get-SelectedFeatureIdsFromBackup -SelectedBackup $backup | Should -Be @('RegistryFeature', 'CustomFeature', 'HiddenFeature')
|
||||
}
|
||||
|
||||
It 'separates visible feature labels into revertible and manual lists' {
|
||||
$lists = Get-RestoreBackupFeatureLists -SelectedFeatureIds @('RegistryFeature', 'CustomFeature', 'HiddenFeature', 'MissingFeature') -Features $script:Features
|
||||
|
||||
$lists.Revertible.DisplayText | Should -Be '- Registry feature'
|
||||
$lists.NonRevertible.DisplayText | Should -Be '- Custom feature'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
BeforeAll {
|
||||
$restoreApplyStateScriptPath = Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryApplyState.ps1'
|
||||
. $restoreApplyStateScriptPath
|
||||
}
|
||||
|
||||
Describe 'Convert-RegistryValueKindFromBackup' {
|
||||
It 'defaults a missing kind to String' {
|
||||
Convert-RegistryValueKindFromBackup -KindName $null | Should -Be ([Microsoft.Win32.RegistryValueKind]::String)
|
||||
}
|
||||
|
||||
It '<Case>' -ForEach @(
|
||||
@{ Case = 'parses registry kinds case-insensitively'; KindName = 'dword'; Expected = [Microsoft.Win32.RegistryValueKind]::DWord; ExpectedError = $null }
|
||||
@{ Case = 'rejects an invalid registry kind'; KindName = 'NotARegistryValueKind'; Expected = $null; ExpectedError = 'Unsupported registry value kind in backup: NotARegistryValueKind' }
|
||||
) {
|
||||
if ($ExpectedError) {
|
||||
{ Convert-RegistryValueKindFromBackup -KindName $KindName } | Should -Throw $ExpectedError
|
||||
}
|
||||
else {
|
||||
Convert-RegistryValueKindFromBackup -KindName $KindName | Should -Be $Expected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Convert-RegistryValueDataFromBackup' {
|
||||
It 'preserves the bit pattern of an unsigned <Kind>' -ForEach @(
|
||||
@{ Kind = [Microsoft.Win32.RegistryValueKind]::DWord; Data = [uint32]::MaxValue; Expected = -1 }
|
||||
@{ Kind = [Microsoft.Win32.RegistryValueKind]::QWord; Data = [uint64]::MaxValue; Expected = -1L }
|
||||
) {
|
||||
Convert-RegistryValueDataFromBackup -Kind $Kind -Data $Data | Should -Be $Expected
|
||||
}
|
||||
|
||||
It 'converts <Case>' -ForEach @(
|
||||
@{ Case = 'a multi-string value'; Kind = [Microsoft.Win32.RegistryValueKind]::MultiString; Data = @(1, 'two'); Expected = @('1', 'two'); ExpectNull = $false }
|
||||
@{ Case = 'a binary value'; Kind = [Microsoft.Win32.RegistryValueKind]::Binary; Data = @('1', 255); Expected = [byte[]](1, 255); ExpectNull = $false }
|
||||
@{ Case = 'a null string to an empty string'; Kind = [Microsoft.Win32.RegistryValueKind]::String; Data = $null; Expected = ''; ExpectNull = $false }
|
||||
) {
|
||||
$result = Convert-RegistryValueDataFromBackup -Kind $Kind -Data $Data
|
||||
if ($ExpectNull) {
|
||||
$result | Should -BeNullOrEmpty
|
||||
}
|
||||
else {
|
||||
$result | Should -Be $Expected
|
||||
if ($Kind -eq [Microsoft.Win32.RegistryValueKind]::MultiString) {
|
||||
$result.GetType() | Should -Be ([string[]])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It 'rejects binary backup data that cannot be represented as bytes' {
|
||||
{ Convert-RegistryValueDataFromBackup -Kind ([Microsoft.Win32.RegistryValueKind]::Binary) -Data @(-1, 256, 'invalid') } |
|
||||
Should -Throw 'Invalid binary registry data in backup*'
|
||||
}
|
||||
|
||||
It 'preserves an empty binary value as a byte array' {
|
||||
$result = Convert-RegistryValueDataFromBackup -Kind ([Microsoft.Win32.RegistryValueKind]::Binary) -Data $null
|
||||
$result | Should -BeNullOrEmpty
|
||||
$result.GetType() | Should -Be ([byte[]])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Describe 'Convert-BackupDataToByteArray' {
|
||||
It 'rejects <Case>' -ForEach @(
|
||||
@{ Case = 'an object value'; Data = [PSCustomObject]@{ Value = 1 } }
|
||||
@{ Case = 'a negative byte'; Data = @(-1) }
|
||||
@{ Case = 'a byte greater than 255'; Data = @(256) }
|
||||
@{ Case = 'non-numeric input'; Data = @('invalid') }
|
||||
) {
|
||||
Convert-BackupDataToByteArray -Data $Data | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
BeforeAll {
|
||||
function Test-RunningAsSystem { $false }
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Registry-PathHelpers.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Get-FriendlyRegistryBackupTarget.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Resolve-UserProfilePath.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Test-TargetUserName.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Registry-BackupValidation.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryApplyState.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Features\Restore-RegistryBackup.ps1')
|
||||
$script:JsonFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading'
|
||||
}
|
||||
|
||||
Describe 'Import-RegistryBackup' {
|
||||
It 'loads JSON and passes the parsed backup to normalization' {
|
||||
Mock ConvertTo-NormalizedRegistryBackup { [PSCustomObject]@{ Target = 'DefaultUserProfile' } }
|
||||
|
||||
$result = Import-RegistryBackup -FilePath (Join-Path $script:JsonFixturePath 'RegistryBackup.Valid.json')
|
||||
|
||||
$result.Target | Should -Be 'DefaultUserProfile'
|
||||
Should -Invoke ConvertTo-NormalizedRegistryBackup -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'parses and normalizes registry snapshots from a real backup structure' {
|
||||
Mock Test-UserNameMatch { $true }
|
||||
Mock Test-RegistryBackupMatchesSelectedFeatures { @() }
|
||||
|
||||
$result = Import-RegistryBackup -FilePath (Join-Path $script:JsonFixturePath 'RegistryBackup.RealStructure.json')
|
||||
|
||||
$result.Target | Should -Be 'CurrentUser:fixture-user'
|
||||
$result.SelectedFeatures | Should -Be @('RemoveApps', 'DisableStickyKeys')
|
||||
$result.SelectedUndoFeatures | Should -Be 'DisableTransparency'
|
||||
$result.RegistryKeys | Should -HaveCount 2
|
||||
$result.RegistryKeys[0].Path | Should -Be 'HKEY_CURRENT_USER\Control Panel\Accessibility\StickyKeys'
|
||||
$result.RegistryKeys[0].Values[0].Name | Should -Be 'Flags'
|
||||
$result.RegistryKeys[0].Values[0].Kind | Should -Be 'String'
|
||||
$result.RegistryKeys[0].Values[0].Data | Should -Be '510'
|
||||
$result.RegistryKeys[1].Path | Should -Be 'HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
|
||||
$result.RegistryKeys[1].Values[0].Name | Should -Be 'EnableTransparency'
|
||||
$result.RegistryKeys[1].Values[0].Kind | Should -Be 'DWord'
|
||||
$result.RegistryKeys[1].Values[0].Data | Should -Be 0
|
||||
Should -Invoke Test-RegistryBackupMatchesSelectedFeatures -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'rejects <Case>' -ForEach @(
|
||||
@{ Case = 'a missing backup file'; FileName = 'missing.json'; ExpectedError = 'Backup file was not found:*' }
|
||||
@{ Case = 'an invalid JSON backup file'; FileName = 'RegistryBackup.Invalid.json'; ExpectedError = $null }
|
||||
) {
|
||||
$path = Join-Path $script:JsonFixturePath $FileName
|
||||
$errorPattern = if ($null -ne $ExpectedError) { $ExpectedError } else { "Failed to read backup file '$path'. The file is not valid JSON." }
|
||||
|
||||
{ Import-RegistryBackup -FilePath $path } | Should -Throw $errorPattern
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'ConvertTo-NormalizedRegistryBackup' {
|
||||
BeforeEach {
|
||||
$script:Features = @{ Example = [PSCustomObject]@{ FeatureId = 'Example'; RegistryKey = 'Example.reg' } }
|
||||
Mock Test-RegistryBackupMatchesSelectedFeatures { @() }
|
||||
Mock Write-Error {}
|
||||
}
|
||||
|
||||
It 'normalizes a valid legacy backup without undo selections' {
|
||||
$backup = [PSCustomObject]@{
|
||||
Version = '1.0'; BackupType = 'RegistryState'; Target = 'DefaultUserProfile'
|
||||
CreatedAt = '2026-01-01T00:00:00.0000000Z'; CreatedBy = 'Win11Debloat'; ComputerName = 'PC'
|
||||
SelectedFeatures = @('Example', 'example'); RegistryKeys = @()
|
||||
}
|
||||
|
||||
$result = ConvertTo-NormalizedRegistryBackup -Backup $backup
|
||||
|
||||
$result.Target | Should -Be 'DefaultUserProfile'
|
||||
$result.SelectedFeatures -is [array] | Should -BeTrue
|
||||
$result.SelectedFeatures | Should -Be 'Example'
|
||||
$result.SelectedUndoFeatures | Should -BeNullOrEmpty
|
||||
Should -Invoke Test-RegistryBackupMatchesSelectedFeatures -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'aggregates invalid metadata and does not attempt allow-list validation without feature IDs' {
|
||||
$backup = [PSCustomObject]@{ Version = '2.0'; BackupType = 'Other'; Target = 'Unknown'; RegistryKeys = @() }
|
||||
|
||||
{ ConvertTo-NormalizedRegistryBackup -Backup $backup } | Should -Throw 'Validation failed with * errors. See console output for details.'
|
||||
|
||||
Should -Invoke Write-Error -Times 1 -Exactly
|
||||
Should -Invoke Test-RegistryBackupMatchesSelectedFeatures -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'reports an invalid user target as a validation failure' {
|
||||
Mock Test-TargetUserName { [PSCustomObject]@{ IsValid = $false } }
|
||||
$backup = [PSCustomObject]@{
|
||||
Version = '1.0'; BackupType = 'RegistryState'; Target = 'User:bad/user'
|
||||
SelectedFeatures = @('Example'); RegistryKeys = @()
|
||||
}
|
||||
|
||||
{ ConvertTo-NormalizedRegistryBackup -Backup $backup } | Should -Throw "Validation failed: Invalid user 'User:bad/user'"
|
||||
}
|
||||
|
||||
It 'does not allow current-user backup restore when running as SYSTEM' {
|
||||
Mock Test-RunningAsSystem { $true }
|
||||
$backup = [PSCustomObject]@{
|
||||
Version = '1.0'; BackupType = 'RegistryState'; Target = 'CurrentUser:Alice'
|
||||
SelectedFeatures = @('Example'); RegistryKeys = @()
|
||||
}
|
||||
|
||||
{ ConvertTo-NormalizedRegistryBackup -Backup $backup } |
|
||||
Should -Throw "Validation failed: Backup was made for 'Alice' and is user-scoped*"
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Restore-RegistryBackupState' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
Mock Get-FriendlyRegistryBackupTarget { 'friendly target' }
|
||||
Mock Restore-RegistryKeySnapshot {}
|
||||
Mock Invoke-WithLoadedRestoreHive {}
|
||||
Mock Write-Host {}
|
||||
$script:backup = [PSCustomObject]@{ Target = 'CurrentUser:Tester'; RegistryKeys = @([PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\One' }, [PSCustomObject]@{ Path = 'HKEY_CURRENT_USER\Software\Two' }) }
|
||||
}
|
||||
|
||||
It 'restores every root snapshot directly for the current user' {
|
||||
(Restore-RegistryBackupState -Backup $script:backup).Result | Should -BeTrue
|
||||
|
||||
Should -Invoke Restore-RegistryKeySnapshot -Times 2 -Exactly
|
||||
Should -Invoke Invoke-WithLoadedRestoreHive -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'delegates default-profile restores through the loaded hive wrapper' {
|
||||
$script:backup.Target = 'DefaultUserProfile'
|
||||
Mock Invoke-WithLoadedRestoreHive {
|
||||
param($Target, $ScriptBlock, $ArgumentObject)
|
||||
& $ScriptBlock $ArgumentObject
|
||||
}
|
||||
|
||||
(Restore-RegistryBackupState -Backup $script:backup).Result | Should -BeTrue
|
||||
|
||||
Should -Invoke Invoke-WithLoadedRestoreHive -Times 1 -Exactly -ParameterFilter { $Target -eq 'DefaultUserProfile' }
|
||||
Should -Invoke Restore-RegistryKeySnapshot -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'honors WhatIf without restoring snapshots or loading a hive' {
|
||||
$script:Params = @{ WhatIf = $true }
|
||||
|
||||
(Restore-RegistryBackupState -Backup $script:backup).Result | Should -BeTrue
|
||||
|
||||
Should -Invoke Restore-RegistryKeySnapshot -Times 0 -Exactly
|
||||
Should -Invoke Invoke-WithLoadedRestoreHive -Times 0 -Exactly
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
BeforeAll {
|
||||
function Wait-ForKeyPress {}
|
||||
function Test-AppInWingetList { param($appId, $InstalledList) $false }
|
||||
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-JsonFile.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\Add-Parameter.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Save-ToFile.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-Settings.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Save-Settings.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-AppsFromFile.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-AppDetailsFromJson.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Import-AppPresetsFromJson.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\FileIO\Get-ValidatedAppList.ps1')
|
||||
$script:JsonFixturePath = Join-Path $PSScriptRoot 'TestData\JsonFileLoading'
|
||||
}
|
||||
|
||||
Describe 'Import-Settings' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:ModernStandbySupported = $false
|
||||
$script:Features = @{
|
||||
Supported = [PSCustomObject]@{ FeatureId = 'Supported'; MinVersion = 22000; MaxVersion = 30000 }
|
||||
TooNew = [PSCustomObject]@{ FeatureId = 'TooNew'; MinVersion = 99999; MaxVersion = $null }
|
||||
DisableModernStandbyNetworking = [PSCustomObject]@{ FeatureId = 'DisableModernStandbyNetworking'; MinVersion = $null; MaxVersion = $null }
|
||||
}
|
||||
Mock Get-ItemPropertyValue { 22631 }
|
||||
Mock Add-Parameter {}
|
||||
Mock Write-Error {}
|
||||
}
|
||||
|
||||
It 'loads enabled, known, compatible settings and skips all other entries' {
|
||||
Import-Settings -filePath (Join-Path $script:JsonFixturePath 'DefaultSettings.Valid.json')
|
||||
|
||||
Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $parameterName -eq 'Supported' -and $value -eq 'configured' }
|
||||
}
|
||||
|
||||
It 'throws when <Case>' -ForEach @(
|
||||
@{ Case = 'the last-used settings JSON is invalid'; FileName = 'LastUsedSettings.Invalid.json'; WriteErrorCalls = 1 }
|
||||
@{ Case = 'the default settings file has no Settings property'; FileName = 'DefaultSettings.MissingSettings.json'; WriteErrorCalls = 0 }
|
||||
) {
|
||||
$path = Join-Path $script:JsonFixturePath $FileName
|
||||
|
||||
{
|
||||
& {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Import-Settings -filePath $path
|
||||
}
|
||||
} | Should -Throw "Failed to load settings from $FileName"
|
||||
Should -Invoke Add-Parameter -Times 0 -Exactly
|
||||
Should -Invoke Write-Error -Times $WriteErrorCalls -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Save-Settings' {
|
||||
BeforeEach {
|
||||
$script:SavedSettingsFilePath = Join-Path $TestDrive 'LastUsedSettings.json'
|
||||
$script:ControlParams = @('Silent', 'WhatIf')
|
||||
$script:Features = @{ Feature = [PSCustomObject]@{ FeatureId = 'Feature' } }
|
||||
Mock Save-ToFile { $true }
|
||||
Mock Write-Host {}
|
||||
Mock Write-Output {}
|
||||
}
|
||||
|
||||
It 'saves only feature parameters and excludes control or unknown parameters' {
|
||||
$script:Params = @{ Feature = 'configured'; Silent = $true; Unknown = 42 }
|
||||
|
||||
Save-Settings
|
||||
|
||||
Should -Invoke Save-ToFile -Times 1 -Exactly -ParameterFilter {
|
||||
$FilePath -eq $script:SavedSettingsFilePath -and
|
||||
@($Config.Settings).Count -eq 1 -and
|
||||
$Config.Settings[0].Name -eq 'Feature' -and
|
||||
$Config.Settings[0].Value -eq 'configured'
|
||||
}
|
||||
}
|
||||
|
||||
It 'does not persist in WhatIf mode' {
|
||||
$script:Params = @{ Feature = $true; WhatIf = $true }
|
||||
|
||||
Save-Settings
|
||||
|
||||
Should -Invoke Save-ToFile -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'reports a persistence failure without throwing' {
|
||||
$script:Params = @{ Feature = $true }
|
||||
Mock Save-ToFile { $false }
|
||||
|
||||
{ Save-Settings } | Should -Not -Throw
|
||||
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like 'Error:*' }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Import-AppsFromFile' {
|
||||
It 'returns selected IDs, trims whitespace, and supports scalar and array AppId values' {
|
||||
$path = Join-Path $TestDrive 'apps.json'
|
||||
'{"Apps":[{"AppId":" One.App ","SelectedByDefault":true},{"AppId":["Two.App"," "],"SelectedByDefault":true},{"AppId":"Ignored.App","SelectedByDefault":false}]}' |
|
||||
Set-Content -LiteralPath $path -Encoding UTF8
|
||||
|
||||
@(Import-AppsFromFile -appsFilePath $path) | Should -Be @('One.App', 'Two.App')
|
||||
}
|
||||
|
||||
It 'returns an empty collection for a missing file' {
|
||||
@(Import-AppsFromFile -appsFilePath (Join-Path $TestDrive 'missing.json')) | Should -HaveCount 0
|
||||
}
|
||||
|
||||
It 'reports invalid JSON and invokes the CLI acknowledgement hook' {
|
||||
$path = Join-Path $TestDrive 'invalid-apps.json'
|
||||
'not json' | Set-Content -LiteralPath $path
|
||||
Mock Write-Error {}
|
||||
Mock Wait-ForKeyPress {}
|
||||
|
||||
@(Import-AppsFromFile -appsFilePath $path) | Should -HaveCount 0
|
||||
Should -Invoke Write-Error -Times 1 -Exactly
|
||||
Should -Invoke Wait-ForKeyPress -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-ValidatedAppList' {
|
||||
It 'normalizes wildcard selections and skips unsupported applications' {
|
||||
Mock Import-AppDetailsFromJson { @([PSCustomObject]@{ AppId = @('One.App', 'Two.App') }) }
|
||||
Mock Write-Host {}
|
||||
|
||||
@(Get-ValidatedAppList -appsList @('*One.App*', ' Missing.App ')) | Should -Be @('One.App')
|
||||
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -like "*Missing.App*" }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Import-AppDetailsFromJson' {
|
||||
BeforeEach {
|
||||
$script:AppsListFilePath = Join-Path $TestDrive 'Apps.json'
|
||||
'{"Apps":[{"AppId":["One.App","Alias.App"],"FriendlyName":"One","SelectedByDefault":true,"RemovalMethod":"WinGet"},{"AppId":"Two.App","SelectedByDefault":false},{"AppId":" ","SelectedByDefault":true}],"Presets":[{"Name":"Minimal","AppIds":["One.App"]}]}' |
|
||||
Set-Content -LiteralPath $script:AppsListFilePath -Encoding UTF8
|
||||
}
|
||||
|
||||
It 'projects app metadata, defaults removal method, and honors initial checked state' {
|
||||
$apps = @(Import-AppDetailsFromJson -InitialCheckedFromJson)
|
||||
|
||||
$apps | Should -HaveCount 2
|
||||
$apps[0].DisplayName | Should -Be 'One (One.App, Alias.App)'
|
||||
$apps[0].IsChecked | Should -BeTrue
|
||||
$apps[0].RemovalMethod | Should -Be 'WinGet'
|
||||
$apps[1].FriendlyName | Should -Be 'Two.App'
|
||||
$apps[1].RemovalMethod | Should -Be 'Appx'
|
||||
$apps[1].AppId -is [array] | Should -BeTrue
|
||||
@($apps[1].AppId) | Should -HaveCount 1
|
||||
}
|
||||
|
||||
It 'skips missing, blank, and non-string app IDs without failing the complete catalog' {
|
||||
'{"Apps":[{"AppId":null},{"FriendlyName":"Missing"},{"AppId":42},{"AppId":[" Valid.App ",null,{},""]}]}' |
|
||||
Set-Content -LiteralPath $script:AppsListFilePath -Encoding UTF8
|
||||
|
||||
$apps = @(Import-AppDetailsFromJson)
|
||||
|
||||
$apps | Should -HaveCount 1
|
||||
$apps[0].AppId -is [array] | Should -BeTrue
|
||||
$apps[0].AppId | Should -Be @('Valid.App')
|
||||
}
|
||||
|
||||
It 'filters to installed apps using Appx and winget detection' {
|
||||
Mock Get-AppxPackage { param($Name) if ($Name -eq 'Two.App') { [PSCustomObject]@{ Name = $Name } } }
|
||||
Mock Test-AppInWingetList { $false }
|
||||
|
||||
$apps = @(Import-AppDetailsFromJson -OnlyInstalled -InstalledList @())
|
||||
|
||||
$apps | Should -HaveCount 1
|
||||
$apps[0].AppId | Should -Be 'Two.App'
|
||||
}
|
||||
|
||||
It 'loads presets and preserves their ID arrays' {
|
||||
$presets = @(Import-AppPresetsFromJson)
|
||||
|
||||
$presets | Should -HaveCount 1
|
||||
$presets[0].Name | Should -Be 'Minimal'
|
||||
$presets[0].AppIds -is [array] | Should -BeTrue
|
||||
$presets[0].AppIds | Should -Be 'One.App'
|
||||
}
|
||||
|
||||
It 'returns empty collections and reports malformed JSON' {
|
||||
'not json' | Set-Content -LiteralPath $script:AppsListFilePath
|
||||
Mock Write-Error {}
|
||||
Mock Write-Warning {}
|
||||
|
||||
@(Import-AppDetailsFromJson) | Should -HaveCount 0
|
||||
@(Import-AppPresetsFromJson) | Should -HaveCount 0
|
||||
Should -Invoke Write-Error -Times 1 -Exactly
|
||||
Should -Invoke Write-Warning -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
BeforeAll {
|
||||
function Get-UserName { 'Alice' }
|
||||
function Generate-AppsList { @() }
|
||||
function Add-Parameter { param($Name, $Value) }
|
||||
function Save-Settings {}
|
||||
function Import-Settings { param($filePath, $expectedVersion) }
|
||||
function Wait-ForKeyPress {}
|
||||
function Show-AppSelectionWindow { $false }
|
||||
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\CLI\Write-CliHeader.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\CLI\Write-PendingChanges.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliAppRemoval.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliDefaultModeAppRemovalOptions.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliDefaultModeOptions.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliLastUsedSettings.ps1')
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\CLI\Show-CliMenuOptions.ps1')
|
||||
}
|
||||
|
||||
Describe 'Show-CliMenuOptions' {
|
||||
BeforeEach {
|
||||
$script:Params = @{}
|
||||
$script:ControlParams = @('WhatIf', 'Silent')
|
||||
$script:Features = @{ DisableTelemetry = [PSCustomObject]@{ Label = 'Disable telemetry' }; CreateRestorePoint = [PSCustomObject]@{ Label = 'Create restore point' } }
|
||||
$script:SelectedApps = @('One.App', 'Two.App')
|
||||
$script:SavedSettingsFilePath = 'C:\Settings\LastUsedSettings.json'
|
||||
$script:DefaultSettingsFilePath = 'C:\Settings\DefaultSettings.json'
|
||||
$script:InputQueue = @()
|
||||
$script:InputIndex = 0
|
||||
$script:Silent = $false
|
||||
$script:RunDefaults = $false
|
||||
$script:RunDefaultsLite = $false
|
||||
Mock Clear-Host {}
|
||||
Mock Write-Host {}
|
||||
Mock Write-Output {}
|
||||
Mock Read-Host { $script:InputQueue[$script:InputIndex++] }
|
||||
Mock Get-UserName { 'Alice' }
|
||||
Mock Generate-AppsList { @('One.App', 'Two.App') }
|
||||
Mock Add-Parameter {}
|
||||
Mock Save-Settings {}
|
||||
Mock Import-Settings {}
|
||||
Mock Show-AppSelectionWindow { $true }
|
||||
Mock Test-Path { $true }
|
||||
}
|
||||
|
||||
It 'prints user or Sysprep context in the CLI header' {
|
||||
$script:Params = @{}
|
||||
Write-CliHeader -title 'Menu'
|
||||
$script:Params = @{ Sysprep = $true }
|
||||
Write-CliHeader -title 'Menu'
|
||||
|
||||
Should -Invoke Write-Host -Times 6 -Exactly
|
||||
Should -Invoke Get-UserName -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'summarizes selected features and app removal before confirmation' {
|
||||
$script:Params = @{ DisableTelemetry = $true; RemoveApps = $true; Apps = 'Default' }
|
||||
$script:ControlParams = @('WhatIf', 'Silent', 'Apps')
|
||||
$script:InputQueue = @('')
|
||||
|
||||
Write-PendingChanges
|
||||
|
||||
Should -Invoke Generate-AppsList -Times 1 -Exactly
|
||||
Should -Invoke Read-Host -Times 1 -Exactly
|
||||
Should -Invoke Write-Output -Times 2 -ParameterFilter { $InputObject -like '- *' }
|
||||
}
|
||||
|
||||
It 'returns the saved-settings menu option only when the file is available' {
|
||||
$script:InputQueue = @('3')
|
||||
|
||||
Show-CliMenuOptions | Should -Be '3'
|
||||
Should -Invoke Test-Path -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 're-prompts after a cancelled manual app selection and accepts no removal' {
|
||||
Mock Show-AppSelectionWindow { $false }
|
||||
$script:InputQueue = @('2', 'n')
|
||||
|
||||
Show-CliDefaultModeAppRemovalOptions | Should -Be 'n'
|
||||
Should -Invoke Show-AppSelectionWindow -Times 1 -Exactly
|
||||
Should -Invoke Read-Host -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'records selected app removal and skips confirmation in silent mode' {
|
||||
$script:Silent = $true
|
||||
|
||||
Show-CliAppRemoval
|
||||
|
||||
Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $Name -eq 'RemoveApps' }
|
||||
Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $Name -eq 'Apps' -and $Value -eq 'One.App,Two.App' }
|
||||
Should -Invoke Save-Settings -Times 1 -Exactly
|
||||
Should -Invoke Read-Host -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'loads saved settings without prompting in silent mode' {
|
||||
$script:Silent = $true
|
||||
|
||||
Show-CliLastUsedSettings
|
||||
|
||||
Should -Invoke Import-Settings -Times 1 -Exactly -ParameterFilter { $filePath -eq $script:SavedSettingsFilePath -and $expectedVersion -eq '1.0' }
|
||||
Should -Invoke Read-Host -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'applies default app-removal parameters for the RunDefaults switch' {
|
||||
$script:RunDefaults = $true
|
||||
$script:Silent = $true
|
||||
|
||||
Show-CliDefaultModeOptions
|
||||
|
||||
Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $Name -eq 'RemoveApps' }
|
||||
Should -Invoke Add-Parameter -Times 1 -Exactly -ParameterFilter { $Name -eq 'Apps' -and $Value -eq 'Default' }
|
||||
Should -Invoke Import-Settings -Times 1 -Exactly
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
BeforeAll {
|
||||
$wingetListScriptPath = Join-Path $PSScriptRoot '..\Scripts\AppRemoval\Test-AppInWingetList.ps1'
|
||||
. $wingetListScriptPath
|
||||
}
|
||||
|
||||
Describe 'Test-AppInWingetList' {
|
||||
BeforeAll {
|
||||
$installedApps = @(
|
||||
[PSCustomObject]@{ Id = 'Microsoft.Copilot' }
|
||||
[PSCustomObject]@{ Id = 'Microsoft.EdgeDev' }
|
||||
[PSCustomObject]@{ Id = 'Contoso.Music-Player' }
|
||||
)
|
||||
}
|
||||
|
||||
It 'matches an exact winget ID' {
|
||||
Test-AppInWingetList -appId 'Microsoft.Copilot' -InstalledList $installedApps | Should -BeTrue
|
||||
}
|
||||
|
||||
It '<Case>' -ForEach @(
|
||||
@{ Case = 'matches a delimited substring'; AppId = 'Music'; Expected = $true }
|
||||
@{ Case = 'does not match an alphanumeric continuation'; AppId = 'Microsoft.Edge'; Expected = $false }
|
||||
) {
|
||||
Test-AppInWingetList -appId $AppId -InstalledList $installedApps | Should -Be $Expected
|
||||
}
|
||||
|
||||
It 'returns false for <Case>' -ForEach @(
|
||||
@{ Case = 'an empty installed-app list'; AppId = 'Microsoft.Copilot'; InstalledList = @() }
|
||||
@{ Case = 'an app that is not installed'; AppId = 'Missing.App'; InstalledList = $null }
|
||||
) {
|
||||
$list = if ($null -eq $InstalledList) { $installedApps } else { $InstalledList }
|
||||
Test-AppInWingetList -appId $AppId -InstalledList $list | Should -BeFalse
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"Version": "1.0",
|
||||
"Name":
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"Version": "1.0",
|
||||
"Name": "Example configuration"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"Version": "2.0",
|
||||
"Name": "Incompatible configuration"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"Version": "1.0"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Version": "1.0",
|
||||
"Settings": [
|
||||
{ "Name": "Supported", "Value": "configured" },
|
||||
{ "Name": "Supported", "Value": false },
|
||||
{ "Name": "Unknown", "Value": true },
|
||||
{ "Name": "TooNew", "Value": true },
|
||||
{ "Name": "DisableModernStandbyNetworking", "Value": true }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"Version": "1.0",
|
||||
"Deployment": [
|
||||
{ "Name": "UserSelectionIndex", "Value": 0 },
|
||||
{ "Name": "AppRemovalScopeIndex", "Value": 0 },
|
||||
{ "Name": "CreateRestorePoint", "Value": true },
|
||||
{ "Name": "RestartExplorer", "Value": false }
|
||||
],
|
||||
"Tweaks": [
|
||||
{ "Name": "DisableSettings365Ads", "Value": true },
|
||||
{ "Name": "DisableSnapAssist", "Value": true },
|
||||
{ "Name": "EnableDarkMode", "Value": true },
|
||||
{ "Name": "ShowSearchBoxTb", "Value": true },
|
||||
{ "Name": "DisableTelemetry", "Value": true },
|
||||
{ "Name": "DisableWidgets", "Value": true },
|
||||
{ "Name": "DisableLockscreenTips", "Value": true },
|
||||
{ "Name": "DisableSnapLayouts", "Value": true },
|
||||
{ "Name": "DisableAISvcAutoStart", "Value": true },
|
||||
{ "Name": "DisableMouseAcceleration", "Value": true },
|
||||
{ "Name": "DisableCopilot", "Value": true },
|
||||
{ "Name": "DisableRecall", "Value": true }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Version": "1.0",
|
||||
"Settings": [
|
||||
{ "Name": "Supported", "Value": true }
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"Version": "1.0",
|
||||
"Settings": [
|
||||
{ "Name": "Supported", "Value": true }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"Version": "1.0",
|
||||
"BackupType":
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"RegistryKeys": [
|
||||
{
|
||||
"SubKeys": [],
|
||||
"Path": "HKEY_CURRENT_USER\\Control Panel\\Accessibility\\StickyKeys",
|
||||
"Values": [
|
||||
{
|
||||
"Kind": "String",
|
||||
"Name": "Flags",
|
||||
"Data": "510",
|
||||
"Exists": true
|
||||
}
|
||||
],
|
||||
"Exists": true
|
||||
},
|
||||
{
|
||||
"SubKeys": [],
|
||||
"Path": "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
|
||||
"Values": [
|
||||
{
|
||||
"Kind": "DWord",
|
||||
"Name": "EnableTransparency",
|
||||
"Data": 0,
|
||||
"Exists": true
|
||||
}
|
||||
],
|
||||
"Exists": true
|
||||
}
|
||||
],
|
||||
"ComputerName": "TEST-COMPUTER",
|
||||
"BackupType": "RegistryState",
|
||||
"SelectedUndoFeatures": [
|
||||
"DisableTransparency"
|
||||
],
|
||||
"CreatedBy": "Win11Debloat",
|
||||
"Version": "1.0",
|
||||
"Target": "CurrentUser:fixture-user",
|
||||
"SelectedFeatures": [
|
||||
"RemoveApps",
|
||||
"DisableStickyKeys"
|
||||
],
|
||||
"CreatedAt": "2026-07-11T20:00:33.9825666+02:00"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"Version": "1.0",
|
||||
"BackupType": "RegistryState",
|
||||
"Target": "DefaultUserProfile",
|
||||
"SelectedFeatures": [],
|
||||
"RegistryKeys": []
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
BeforeAll {
|
||||
function Normalize-UserLookupValue { param($Value) ([string]$Value).Trim() }
|
||||
function Resolve-UserProfileContext { param($UserName) $null }
|
||||
function reg { param($Action, $Mount, $Path) $global:LASTEXITCODE = 0 }
|
||||
. (Join-Path $PSScriptRoot '..\Scripts\Helpers\User-HiveHelpers.ps1')
|
||||
}
|
||||
|
||||
Describe 'New-TargetUserHiveContext' {
|
||||
It 'projects user information and defaults an empty mount name' {
|
||||
$result = New-TargetUserHiveContext -TargetUserName 'Alice' -UserContext ([PSCustomObject]@{ UserSid = 'S-1'; ProfilePath = 'C:\Users\Alice' }) -HiveDatPath 'C:\Users\Alice\NTUSER.DAT' -MountName ''
|
||||
|
||||
$result.TargetUserName | Should -Be 'Alice'
|
||||
$result.UserSid | Should -Be 'S-1'
|
||||
$result.MountName | Should -Be 'Default'
|
||||
$result.WasAlreadyLoaded | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Resolve-TargetUserHiveContext' {
|
||||
BeforeEach {
|
||||
Mock Normalize-UserLookupValue { param($Value) ([string]$Value).Trim() }
|
||||
Mock Resolve-UserProfileContext { [PSCustomObject]@{ UserSid = 'S-1-5-21-123'; ProfilePath = $TestDrive } }
|
||||
Mock Test-Path { $true }
|
||||
}
|
||||
|
||||
It 'uses an already loaded SID hive for a normal user' {
|
||||
$result = Resolve-TargetUserHiveContext -TargetUserName ' Alice '
|
||||
|
||||
$result.MountName | Should -Be 'S-1-5-21-123'
|
||||
$result.WasAlreadyLoaded | Should -BeTrue
|
||||
$result.WasLoadedByScript | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'uses the temporary Default mount for <Case>' -ForEach @(
|
||||
@{ Case = 'an unloaded user'; User = 'Alice'; Sid = '' }
|
||||
@{ Case = 'the default profile'; User = 'Default'; Sid = 'S-1-5-21-123' }
|
||||
) {
|
||||
Mock Resolve-UserProfileContext { [PSCustomObject]@{ UserSid = $Sid; ProfilePath = $TestDrive } }
|
||||
Mock Test-Path { param($LiteralPath) $LiteralPath -like '*NTUSER.DAT' }
|
||||
|
||||
$result = Resolve-TargetUserHiveContext -TargetUserName $User
|
||||
|
||||
$result.MountName | Should -Be 'Default'
|
||||
$result.WasAlreadyLoaded | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'rejects <Case>' -ForEach @(
|
||||
@{ Case = 'an empty user name'; UserName = ' '; Setup = 'None'; ExpectedError = 'Target user name for registry hive resolution is empty.' }
|
||||
@{ Case = 'an unresolved profile'; UserName = 'Missing'; Setup = 'MissingProfile'; ExpectedError = "Unable to resolve profile path for target user 'Missing'." }
|
||||
@{ Case = 'a missing hive file'; UserName = 'Alice'; Setup = 'MissingHive'; ExpectedError = 'Unable to find target user hive at *' }
|
||||
) {
|
||||
if ($Setup -eq 'MissingProfile') {
|
||||
Mock Resolve-UserProfileContext { $null }
|
||||
}
|
||||
elseif ($Setup -eq 'MissingHive') {
|
||||
Mock Resolve-UserProfileContext { [PSCustomObject]@{ UserSid = 'S-1'; ProfilePath = $TestDrive } }
|
||||
Mock Test-Path { $false }
|
||||
}
|
||||
|
||||
{ Resolve-TargetUserHiveContext -TargetUserName $UserName } | Should -Throw $ExpectedError
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Resolve-LoadedTargetUserHiveContext' {
|
||||
It '<Case>' -ForEach @(
|
||||
@{ Case = 'returns a loaded context when a SID hive is mounted'; Sid = 'S-1'; HiveMounted = $true; ExpectedLoaded = $true }
|
||||
@{ Case = 'returns null when the SID is empty'; Sid = ''; HiveMounted = $true; ExpectedLoaded = $false }
|
||||
@{ Case = 'returns null when the SID hive is not mounted'; Sid = 'S-1'; HiveMounted = $false; ExpectedLoaded = $false }
|
||||
) {
|
||||
$input = [PSCustomObject]@{ TargetUserName = 'Alice'; UserSid = $Sid; ProfilePath = 'C:\Users\Alice'; HiveDatPath = 'C:\Users\Alice\NTUSER.DAT' }
|
||||
Mock Test-Path { $HiveMounted }
|
||||
|
||||
$result = Resolve-LoadedTargetUserHiveContext -HiveContext $input
|
||||
if ($ExpectedLoaded) {
|
||||
$result.WasAlreadyLoaded | Should -BeTrue
|
||||
}
|
||||
else {
|
||||
$result | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Invoke-WithTargetUserHive' {
|
||||
BeforeEach {
|
||||
$script:RegistryTargetHiveMountName = 'Previous'
|
||||
$script:context = [PSCustomObject]@{
|
||||
TargetUserName = 'Alice'; UserSid = 'S-1'; ProfilePath = 'C:\Users\Alice'; HiveDatPath = 'C:\Users\Alice\NTUSER.DAT'
|
||||
MountName = 'Temporary'; WasAlreadyLoaded = $false; WasLoadedByScript = $false
|
||||
}
|
||||
Mock Resolve-TargetUserHiveContext { $script:context }
|
||||
Mock Resolve-LoadedTargetUserHiveContext { $null }
|
||||
Mock reg { $global:LASTEXITCODE = 0 }
|
||||
Mock Write-Warning {}
|
||||
}
|
||||
|
||||
It 'loads, executes, passes context, unloads, and restores the previous mount name' {
|
||||
$result = Invoke-WithTargetUserHive -TargetUserName 'Alice' -ArgumentObject 'payload' -PassHiveContext -ScriptBlock {
|
||||
param($Argument, $Context)
|
||||
"$Argument|$($Context.MountName)|$script:RegistryTargetHiveMountName"
|
||||
}
|
||||
|
||||
$result | Should -Be 'payload|Temporary|Temporary'
|
||||
Should -Invoke reg -Times 1 -Exactly -ParameterFilter { $Action -eq 'load' }
|
||||
Should -Invoke reg -Times 1 -Exactly -ParameterFilter { $Action -eq 'unload' }
|
||||
$script:RegistryTargetHiveMountName | Should -Be 'Previous'
|
||||
}
|
||||
|
||||
It 'does not load or unload a hive that was already mounted' {
|
||||
$script:context.WasAlreadyLoaded = $true
|
||||
|
||||
Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { 'done' } | Should -Be 'done'
|
||||
Should -Invoke reg -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'unloads and restores state when the scriptblock throws' {
|
||||
{ Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { throw 'script failed' } } | Should -Throw 'script failed'
|
||||
|
||||
Should -Invoke reg -Times 1 -Exactly -ParameterFilter { $Action -eq 'unload' }
|
||||
$script:RegistryTargetHiveMountName | Should -Be 'Previous'
|
||||
}
|
||||
|
||||
It 'throws when loading fails without an already-loaded SID fallback' {
|
||||
Mock reg { if ($Action -eq 'load') { $global:LASTEXITCODE = 5 } }
|
||||
|
||||
{ Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { 'never' } } |
|
||||
Should -Throw "Failed to load target user hive 'C:\Users\Alice\NTUSER.DAT' (exit code: 5)."
|
||||
Should -Invoke reg -Times 0 -Exactly -ParameterFilter { $Action -eq 'unload' }
|
||||
}
|
||||
|
||||
It 'uses a loaded SID fallback after a load race' {
|
||||
Mock reg { if ($Action -eq 'load') { $global:LASTEXITCODE = 5 } }
|
||||
Mock Resolve-LoadedTargetUserHiveContext {
|
||||
[PSCustomObject]@{ TargetUserName = 'Alice'; UserSid = 'S-1'; ProfilePath = 'C:\Users\Alice'; HiveDatPath = 'C:\Users\Alice\NTUSER.DAT'; MountName = 'S-1'; WasAlreadyLoaded = $true; WasLoadedByScript = $false }
|
||||
}
|
||||
|
||||
Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { $script:RegistryTargetHiveMountName } | Should -Be 'S-1'
|
||||
Should -Invoke reg -Times 0 -Exactly -ParameterFilter { $Action -eq 'unload' }
|
||||
}
|
||||
|
||||
It 'warns on unload failure without discarding the scriptblock result' {
|
||||
Mock reg {
|
||||
if ($Action -eq 'load') { $global:LASTEXITCODE = 0 }
|
||||
if ($Action -eq 'unload') { $global:LASTEXITCODE = 5 }
|
||||
}
|
||||
|
||||
Invoke-WithTargetUserHive -TargetUserName 'Alice' -ScriptBlock { 'result' } | Should -Be 'result'
|
||||
Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -like "Failed to unload registry hive*" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
BeforeAll {
|
||||
$userProfileScriptPath = Join-Path $PSScriptRoot '..\Scripts\Helpers\Resolve-UserProfilePath.ps1'
|
||||
. $userProfileScriptPath
|
||||
}
|
||||
|
||||
Describe 'Normalize-UserLookupValue' {
|
||||
It 'removes zero-width characters and collapses whitespace' {
|
||||
Normalize-UserLookupValue -Value " domain\u$([char]0x200B)ser name " | Should -Be 'domain\user name'
|
||||
}
|
||||
|
||||
It 'uses normalized, case-insensitive cache keys' {
|
||||
Get-UserLookupCacheKey -Value ' DOMAIN\Alice ' | Should -Be 'domain\alice'
|
||||
Get-UserLookupCacheKey -Value ' ' | Should -Be ''
|
||||
}
|
||||
|
||||
It 'normalizes, filters, and de-duplicates lookup candidates' {
|
||||
$result = Get-NormalizedLookupCandidates -Candidates @(' Alice ', '', 'alice', "Bob$([char]0x200B)")
|
||||
|
||||
$result | Should -HaveCount 3
|
||||
$result[0] | Should -Be 'Alice'
|
||||
$result[1] | Should -Be 'alice'
|
||||
$result[2] | Should -Be 'Bob'
|
||||
}
|
||||
|
||||
It 'escapes WQL literals and extracts local user-name segments' {
|
||||
Escape-WqlString -Value "O'Brian" | Should -Be "O''Brian"
|
||||
Get-LocalUserNameSegment -UserName 'CONTOSO\Alice' | Should -Be 'Alice'
|
||||
Get-LocalUserNameSegment -UserName 'alice@contoso.com' | Should -Be 'alice'
|
||||
Get-LocalUserNameSegment -UserName ' Alice ' | Should -Be 'Alice'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user