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