diff --git a/.github/workflows/unittests.yaml b/.github/workflows/unittests.yaml index 68d0e563..b17cd74f 100644 --- a/.github/workflows/unittests.yaml +++ b/.github/workflows/unittests.yaml @@ -27,13 +27,16 @@ jobs: - name: Install Pester run: | Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process - Install-Module -Name Pester -Force -SkipPublisherCheck -AllowClobber + Install-Module -Name Pester -RequiredVersion 5.8.0 -Force -SkipPublisherCheck -AllowClobber + Import-Module Pester -RequiredVersion 5.8.0 -Force + Get-Module Pester | Select-Object Name, Version, Path shell: pwsh - name: Run Pester tests run: | Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process - Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed + Import-Module Pester -RequiredVersion 5.8.0 -Force + Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI shell: pwsh env: diff --git a/.gitignore b/.gitignore index a97fe97d..038c7354 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ Microsoft.PowerShell.ConsoleHost.dll winutil.exe.config winutil.ps1 binary/ +testResults.xml # general software/os specific desktop.ini diff --git a/AGENTS.md b/AGENTS.md index 072010a5..e28a4498 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ WinUtil is a Windows PowerShell utility with a WPF interface. The repository is ``` - Run tests: ```powershell + Import-Module Pester -RequiredVersion 5.8.0 -Force Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed ``` - Run Script Analyzer with project settings when available: @@ -182,4 +183,8 @@ Proceed without asking when: When the user corrects an agent approach, add or tighten one concrete rule here before ending the session. Keep this section short and prune rules that no longer matter. - Keep `winutil.ps1` generated-only: change source files, compile to verify, and never stage the generated script. - +- Keep WinUtil runtime logging in the existing timestamped `%LocalAppData%\winutil\logs\winutil_*.log` session file; do not create a separate root `winutil.log`. +- Import Pester 5.8.0 before running tests so `Invoke-Pester -Output Detailed -CI` does not resolve to Windows' inbox Pester 3.4.0. +- Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey. +- When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic. +- Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity. diff --git a/SPEC.md b/SPEC.md index 780dc636..62e9225e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -94,7 +94,8 @@ Expected validation for source changes: - `.\Compile.ps1` verifies the compiler can generate `winutil.ps1`. - `.\Compile.ps1 -Run` compiles and launches the generated utility for manual GUI verification. -- `Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed` runs the Pester suite. +- `Install-Module -Name Pester -RequiredVersion 5.8.0 -Scope CurrentUser -Force -SkipPublisherCheck` installs the supported Pester version. +- `Import-Module Pester -RequiredVersion 5.8.0 -Force; Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI` runs the Pester suite. - GitHub Actions also runs a compile check and PowerShell Script Analyzer with `lint/PSScriptAnalyser.ps1`. The generated `winutil.ps1` may appear locally after compile. It remains ignored build output and must not be committed. @@ -102,4 +103,3 @@ The generated `winutil.ps1` may appear locally after compile. It remains ignored ## Release Artifact GitHub Actions is responsible for producing the release `winutil.ps1` from repository sources. A release should be considered valid only if the generated script came from the compile process, not from direct manual edits to `winutil.ps1`. - diff --git a/TEST_TODO.md b/TEST_TODO.md new file mode 100644 index 00000000..d117d0b7 --- /dev/null +++ b/TEST_TODO.md @@ -0,0 +1,107 @@ +# Test TODO + +Current suite status: broad parser, import, config JSON, compile, and runspace smoke coverage exists, but runtime behavior coverage is still very thin. Work from the top down; the first items are the highest-value gaps. + +## P0 - Highest Priority + +- [x] Add config reference integrity tests. + - Verify every preset entry points to an existing tweak, feature, app, appx item, or supported action. + - Verify `appnavigation.json` entries point to real tabs/panels and valid config groups. + - Verify tweak, feature, DNS, appx, and theme entries include all fields used by the UI/rendering code. + - Verify all embedded script strings in `config/*.json` parse as PowerShell when wrapped as scriptblocks. + +- [x] Add XAML/control wiring tests. + - Load `xaml/inputXML.xaml` as XML and verify expected named controls exist. + - Verify controls referenced through `$sync["Name"]`, `$sync.Name`, or event handlers exist in XAML or are intentionally created dynamically. + - Verify `Invoke-WPF*` handler names used by buttons/tabs correspond to real functions. + - Verify core tabs, search controls, install panels, tweak panels, and Win11 Creator controls are present. + +- [x] Add mock-based tests for registry and service helpers. + - Cover `Set-WinUtilRegistry` set/remove paths with `Mock New-Item`, `Mock Set-ItemProperty`, and `Mock Remove-ItemProperty`. + - Cover `Set-WinUtilService` for missing services, startup type changes, and no-op behavior. + - Assert command parameters instead of touching the real registry or services. + +- [x] Add package selection and package manager tests. + - Cover `Get-WinUtilSelectedPackages` for winget-only, choco-only, mixed, `na`, empty, duplicate, and missing package entries. + - Cover `Test-WinUtilPackageManager` with mocked `Get-Command`. + - Cover `Install-WinUtilProgramWinget` and `Install-WinUtilProgramChoco` with mocked `Start-Process`, including install/uninstall arguments. + +- [x] Add runspace behavior tests beyond the smoke test. + - Cover no argument list, one named parameter, multiple named parameters, and scriptblock failures. + - Assert callers receive a single `IAsyncResult`. + - Verify failure output/errors can be retrieved by the owning PowerShell instance. + - Add focused tests for public functions that call `Invoke-WPFRunspace` without executing their destructive inner work. + +## P1 - Important Workflow Coverage + +- [x] Add tweak execution orchestration tests. + - Cover `Invoke-WinUtilTweaks` with mocked `Invoke-WinUtilScript`, `Set-WinUtilRegistry`, `Set-WinUtilService`, and `Set-WinUtilDNS`. + - Verify undo mode uses `OriginalValue` and `OriginalType`. + - Verify DNS provider and progress counters are passed through correctly. + +- [x] Add install/uninstall workflow tests. + - Cover `Invoke-WPFInstall` and `Invoke-WPFUnInstall` with mocked package sorting and mocked installers. + - Verify empty selection prompts and exits. + - Verify process-running guard prevents a second install/uninstall. + - Verify taskbar/progress cleanup happens on success and error. + +- [x] Add update profile tests. + - Cover `Invoke-WPFUpdatesdisable`, `Invoke-WPFUpdatesdefault`, and `Invoke-WPFUpdatessecurity` with mocks for registry, services, scheduled tasks, and process calls. + - Assert intended registry paths, values, service startup types, and scheduled task paths. + - Keep all tests non-mutating. + +- [x] Add AppX removal tests. + - Cover `Remove-WinUtilAPPX` with mocked `Get-AppxPackage` and `Remove-AppxPackage`. + - Cover `Invoke-WPFAppxRemoval` selection behavior and runspace parameter passing. + - Verify empty selection is handled without removal calls. + +- [x] Add UI selection/state helper tests. + - Cover `Update-WinUtilSelections`, `Reset-WPFCheckBoxes`, `Invoke-WPFSelectedCheckboxesUpdate`, and `Invoke-WPFToggleAllCategories`. + - Use small fake checkbox/control objects instead of WPF windows where possible. + - Verify selected apps, tweaks, toggles, appx, and features stay in sync with checkbox state. + +## P2 - Domain-Specific Coverage + +- [x] Expand Win11 Creator tests. + - Test edition name to edition ID mapping. + - Test `ei.cfg` and `PID.txt` generation/removal behavior in a temp directory. + - Test autounattend conversion preserves required nodes and removes unsupported product key paths. + - Test driver injection branching with mocked DISM commands. + - Test ISO export fallback when `oscdimg.exe` is missing and winget install fails. + +- [x] Add preferences/theme tests. + - Cover `Set-Preferences` loading, saving, old preference cleanup, and defaults. + - Cover theme resource updates without opening the full GUI. + - Verify theme config contains required brushes/colors used by the theme code. + +- [x] Add search/filter tests. + - Cover `Find-AppsByNameOrDescription` and `Find-TweaksByNameOrDescription` with fake UI item trees. + - Verify empty search restores visibility. + - Verify category labels and panels hide/show correctly. + - Verify description and display-name matches both work. + +- [x] Add compile contract tests. + - Verify compiled config key transformation for `applications.json` adds `WPFInstall` prefixes. + - Verify source ordering in `winutil.ps1`: start script, functions, configs, XAML, autounattend XML, main script. + - Verify generated build date placeholder is replaced. + - Verify generated script does not contain unresolved `#{replaceme}`. + +## P3 - Nice To Have + +- [ ] Add Script Analyzer as a failing CI gate once existing findings are triaged. +- [ ] Add docs generation/link checks for `docs/content`. +- [ ] Add coverage reporting in CI as informational output. +- [ ] Add a lightweight test helper module for fake `$sync`, fake WPF controls, and common mocks. +- [ ] Add regression tests whenever a production bug is fixed. + +## Guardrails + +- Tests must not mutate the real registry, services, scheduled tasks, package managers, AppX packages, USB disks, ISOs, or user profile state. +- Prefer Pester mocks and temp directories for system-facing code. +- Keep generated `winutil.ps1` ignored and never commit it. +- Run the full gate before marking test work complete: + +```powershell +Import-Module Pester -RequiredVersion 5.8.0 -Force +Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI +``` diff --git a/docs/content/dev/architecture.md b/docs/content/dev/architecture.md index 11ca1e4d..490b64a7 100644 --- a/docs/content/dev/architecture.md +++ b/docs/content/dev/architecture.md @@ -578,7 +578,9 @@ Tests are in `/pester/`: Run tests: ```powershell -Invoke-Pester +Install-Module -Name Pester -RequiredVersion 5.8.0 -Scope CurrentUser -Force -SkipPublisherCheck +Import-Module Pester -RequiredVersion 5.8.0 -Force +Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI ``` ## Build Process diff --git a/functions/private/Get-WinUtilPackageLogSummary.ps1 b/functions/private/Get-WinUtilPackageLogSummary.ps1 new file mode 100644 index 00000000..b588c22d --- /dev/null +++ b/functions/private/Get-WinUtilPackageLogSummary.ps1 @@ -0,0 +1,28 @@ +function Get-WinUtilPackageLogSummary { + param( + [Parameter(Mandatory = $true)] + [object[]]$Packages, + + [Parameter(Mandatory = $true)] + [PackageManagers]$Preference + ) + + @($Packages | ForEach-Object { + $package = $_ + $packageName = @($package.Name, $package.Description, $package.winget, $package.choco) | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) -and $_ -ne "na" } | + Select-Object -First 1 + + if ([string]::IsNullOrWhiteSpace([string]$packageName)) { + $packageName = "Unknown package" + } + + if ($Preference -eq [PackageManagers]::Choco -and -not [string]::IsNullOrWhiteSpace([string]$package.choco) -and $package.choco -ne "na") { + "$packageName (choco: $($package.choco))" + } elseif (-not [string]::IsNullOrWhiteSpace([string]$package.winget) -and $package.winget -ne "na") { + "$packageName (winget: $($package.winget))" + } else { + "$packageName (no package id)" + } + }) +} diff --git a/functions/private/Get-WinUtilSelectedPackages.ps1 b/functions/private/Get-WinUtilSelectedPackages.ps1 index dc67c0c7..dae05524 100644 --- a/functions/private/Get-WinUtilSelectedPackages.ps1 +++ b/functions/private/Get-WinUtilSelectedPackages.ps1 @@ -21,17 +21,32 @@ function Get-WinUtilSelectedPackages { $packages[[PackageManagers]::Winget] = $packagesWinget $packages[[PackageManagers]::Choco] = $packagesChoco + function Add-PackageId { + param( + [System.Collections.ArrayList]$Target, + $PackageId + ) + + if ([string]::IsNullOrWhiteSpace([string]$PackageId) -or $PackageId -eq "na") { + return + } + + if (-not $Target.Contains($PackageId)) { + $null = $Target.Add($PackageId) + } + } + foreach ($package in $PackageList) { switch ($Preference) { "Choco" { - if ($package.choco -eq "na") { - $null = $packagesWinget.add($package.winget) + if ([string]::IsNullOrWhiteSpace([string]$package.choco) -or $package.choco -eq "na") { + Add-PackageId -Target $packagesWinget -PackageId $package.winget } else { - $null = $packagesChoco.add($package.choco) + Add-PackageId -Target $packagesChoco -PackageId $package.choco } } "Winget" { - $null = $packagesWinget.add($package.winget) + Add-PackageId -Target $packagesWinget -PackageId $package.winget } } } diff --git a/functions/private/Install-WinUtilProgramChoco.ps1 b/functions/private/Install-WinUtilProgramChoco.ps1 index 4c673cdd..9d9853e5 100644 --- a/functions/private/Install-WinUtilProgramChoco.ps1 +++ b/functions/private/Install-WinUtilProgramChoco.ps1 @@ -9,8 +9,12 @@ function Install-WinUtilProgramChoco { ) if ($Action -eq 'Install') { - Start-Process -FilePath choco -ArgumentList "install $Programs -y" -NoNewWindow -Wait + $arguments = "install $Programs -y" } else { - Start-Process -FilePath choco -ArgumentList "uninstall $Programs -y" -NoNewWindow -Wait + $arguments = "uninstall $Programs -y" } + + Write-WinUtilLog -Component "Package" -Message "$Action choco package(s): $($Programs -join ', ')" + $process = Start-Process -FilePath choco -ArgumentList $arguments -NoNewWindow -Wait -PassThru + Write-WinUtilLog -Component "Package" -Message "$Action choco package(s) completed: $($Programs -join ', ') (exit code: $($process.ExitCode))" } diff --git a/functions/private/Install-WinUtilProgramWinget.ps1 b/functions/private/Install-WinUtilProgramWinget.ps1 index 3ab8ef54..d4b6f1f4 100644 --- a/functions/private/Install-WinUtilProgramWinget.ps1 +++ b/functions/private/Install-WinUtilProgramWinget.ps1 @@ -20,9 +20,13 @@ Function Install-WinUtilProgramWinget { } if ($Action -eq 'Install') { - Start-Process -FilePath winget -ArgumentList @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent") -NoNewWindow -Wait + $arguments = @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent") } else { - Start-Process -FilePath winget -ArgumentList @("uninstall", "--id", $program, "--source", $source, "--silent") -NoNewWindow -Wait + $arguments = @("uninstall", "--id", $program, "--source", $source, "--silent") } + + Write-WinUtilLog -Component "Package" -Message "$Action winget package: $program (source: $source)" + $process = Start-Process -FilePath winget -ArgumentList $arguments -NoNewWindow -Wait -PassThru + Write-WinUtilLog -Component "Package" -Message "$Action winget package completed: $program (exit code: $($process.ExitCode))" } } diff --git a/functions/private/Invoke-WinUtilFeatureInstall.ps1 b/functions/private/Invoke-WinUtilFeatureInstall.ps1 index 1017223f..560bde65 100644 --- a/functions/private/Invoke-WinUtilFeatureInstall.ps1 +++ b/functions/private/Invoke-WinUtilFeatureInstall.ps1 @@ -1,16 +1,22 @@ function Invoke-WinUtilFeatureInstall ($CheckBox) { + Write-WinUtilLog -Component "Feature" -Message "Applying feature action: $CheckBox" if ($sync.configs.feature.$CheckBox.feature) { foreach ($feature in $sync.configs.feature.$CheckBox.feature) { Write-Host "Installing $feature" + Write-WinUtilLog -Component "Feature" -Message "Enabling Windows optional feature: $feature" Enable-WindowsOptionalFeature -Online -FeatureName $feature -All -NoRestart -ErrorAction Stop + Write-WinUtilLog -Component "Feature" -Message "Enabled Windows optional feature: $feature" } } if ($sync.configs.feature.$CheckBox.InvokeScript) { foreach ($script in $sync.configs.feature.$CheckBox.InvokeScript) { Write-Host "Running Script for $CheckBox" + Write-WinUtilLog -Component "Feature" -Message "Running feature script for: $CheckBox" Invoke-Command -ScriptBlock ([scriptblock]::Create($script)) -ErrorAction Stop + Write-WinUtilLog -Component "Feature" -Message "Completed feature script for: $CheckBox" } } + Write-WinUtilLog -Component "Feature" -Message "Feature action completed: $CheckBox" } diff --git a/functions/private/Invoke-WinUtilScript.ps1 b/functions/private/Invoke-WinUtilScript.ps1 index d49b9f46..59a98541 100644 --- a/functions/private/Invoke-WinUtilScript.ps1 +++ b/functions/private/Invoke-WinUtilScript.ps1 @@ -22,23 +22,30 @@ function Invoke-WinUtilScript { try { Write-Host "Running Script for $Name" + Write-WinUtilLog -Component "Script" -Message "Running script for $Name" Invoke-Command $scriptblock -ErrorAction Stop + Write-WinUtilLog -Component "Script" -Message "Completed script for $Name" } catch [System.Management.Automation.CommandNotFoundException] { Write-Warning "The specified command was not found." Write-Warning $PSItem.Exception.message + Write-WinUtilLog -Level "ERROR" -Component "Script" -Message "Command not found while running script for $Name`: $($PSItem.Exception.Message)" } catch [System.Management.Automation.RuntimeException] { Write-Warning "A runtime exception occurred." Write-Warning $PSItem.Exception.message + Write-WinUtilLog -Level "ERROR" -Component "Script" -Message "Runtime exception while running script for $Name`: $($PSItem.Exception.Message)" } catch [System.Security.SecurityException] { Write-Warning "A security exception occurred." Write-Warning $PSItem.Exception.message + Write-WinUtilLog -Level "ERROR" -Component "Script" -Message "Security exception while running script for $Name`: $($PSItem.Exception.Message)" } catch [System.UnauthorizedAccessException] { Write-Warning "Access denied. You do not have permission to perform this operation." Write-Warning $PSItem.Exception.message + Write-WinUtilLog -Level "ERROR" -Component "Script" -Message "Access denied while running script for $Name`: $($PSItem.Exception.Message)" } catch { # Generic catch block to handle any other type of exception Write-Warning "Unable to run script for $Name due to unhandled exception." Write-Warning $psitem.Exception.StackTrace + Write-WinUtilLog -Level "ERROR" -Component "Script" -Message "Unhandled exception while running script for $Name`: $($psitem.Exception.Message)" } } diff --git a/functions/private/Invoke-WinUtilTweaks.ps1 b/functions/private/Invoke-WinUtilTweaks.ps1 index f3812c88..2576f7f9 100644 --- a/functions/private/Invoke-WinUtilTweaks.ps1 +++ b/functions/private/Invoke-WinUtilTweaks.ps1 @@ -21,6 +21,9 @@ function Invoke-WinUtilTweaks { $KeepServiceStartup = $true ) + $action = if ($undo) { "Undo" } else { "Apply" } + Write-WinUtilLog -Component "Tweaks" -Message "$action tweak: $CheckBox" + if ($undo) { $Values = @{ Registry = "OriginalValue" @@ -77,4 +80,5 @@ function Invoke-WinUtilTweaks { } } } + Write-WinUtilLog -Component "Tweaks" -Message "$action tweak completed: $CheckBox" } diff --git a/functions/private/Remove-WinUtilAPPX.ps1 b/functions/private/Remove-WinUtilAPPX.ps1 index 12c0ed42..38759157 100644 --- a/functions/private/Remove-WinUtilAPPX.ps1 +++ b/functions/private/Remove-WinUtilAPPX.ps1 @@ -16,6 +16,8 @@ function Remove-WinUtilAPPX { ) Write-Host "Removing $Name" + Write-WinUtilLog -Component "AppX" -Message "Removing AppX package pattern: $Name" Get-AppxPackage $Name -AllUsers | Remove-AppxPackage -AllUsers Get-AppxProvisionedPackage -Online | Where-Object DisplayName -like $Name | Remove-AppxProvisionedPackage -Online + Write-WinUtilLog -Component "AppX" -Message "AppX removal completed for package pattern: $Name" } diff --git a/functions/private/Set-WinUtilDNS.ps1 b/functions/private/Set-WinUtilDNS.ps1 index 04be90be..f96a5682 100644 --- a/functions/private/Set-WinUtilDNS.ps1 +++ b/functions/private/Set-WinUtilDNS.ps1 @@ -12,22 +12,31 @@ function Set-WinUtilDNS { #> param($DNSProvider) - if($DNSProvider -eq "Default") {return} + if($DNSProvider -eq "Default") { + Write-WinUtilLog -Component "DNS" -Message "DNS provider is Default; no DNS changes applied." + return + } try { $Adapters = Get-NetAdapter | Where-Object {$_.Status -eq "Up"} Write-Host "Ensuring DNS is set to $DNSProvider on the following interfaces:" Write-Host $($Adapters | Out-String) + Write-WinUtilLog -Component "DNS" -Message "Setting DNS provider to $DNSProvider for $(@($Adapters).Count) active adapter(s)." Foreach ($Adapter in $Adapters) { if($DNSProvider -eq "DHCP") { + Write-WinUtilLog -Component "DNS" -Message "Resetting DNS to DHCP on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex))." Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ResetServerAddresses } else { + Write-WinUtilLog -Component "DNS" -Message "Setting IPv4 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($sync.configs.dns.$DNSProvider.Primary), $($sync.configs.dns.$DNSProvider.Secondary)." Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses ("$($sync.configs.dns.$DNSProvider.Primary)", "$($sync.configs.dns.$DNSProvider.Secondary)") + Write-WinUtilLog -Component "DNS" -Message "Setting IPv6 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($sync.configs.dns.$DNSProvider.Primary6), $($sync.configs.dns.$DNSProvider.Secondary6)." Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses ("$($sync.configs.dns.$DNSProvider.Primary6)", "$($sync.configs.dns.$DNSProvider.Secondary6)") } } + Write-WinUtilLog -Component "DNS" -Message "DNS provider change completed: $DNSProvider" } catch { Write-Warning "Unable to set DNS Provider due to an unhandled exception." Write-Warning $psitem.Exception.StackTrace + Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "Unable to set DNS provider $DNSProvider`: $($psitem.Exception.Message)" } } diff --git a/functions/private/Set-WinUtilRegistry.ps1 b/functions/private/Set-WinUtilRegistry.ps1 index 3eacaf45..b15b3e59 100644 --- a/functions/private/Set-WinUtilRegistry.ps1 +++ b/functions/private/Set-WinUtilRegistry.ps1 @@ -32,25 +32,32 @@ function Set-WinUtilRegistry { If (!(Test-Path $Path)) { Write-Host "$Path was not found. Creating..." + Write-WinUtilLog -Component "Registry" -Message "Creating registry path: $Path" New-Item -Path $Path -Force -ErrorAction Stop | Out-Null } if ($Value -ne "") { Write-Host "Set $Path\$Name to $Value" + Write-WinUtilLog -Component "Registry" -Message "Setting $Path\$Name ($Type) to $Value" Set-ItemProperty -Path $Path -Name $Name -Type $Type -Value $Value -Force -ErrorAction Stop | Out-Null } else{ Write-Host "Remove $Path\$Name" + Write-WinUtilLog -Component "Registry" -Message "Removing $Path\$Name" Remove-ItemProperty -Path $Path -Name $Name -Force -ErrorAction Stop | Out-Null } } catch [System.Security.SecurityException] { Write-Warning "Unable to set $Path\$Name to $Value due to a Security Exception." + Write-WinUtilLog -Level "ERROR" -Component "Registry" -Message "Security exception while changing $Path\$Name to $Value`: $($psitem.Exception.Message)" } catch [System.Management.Automation.ItemNotFoundException] { Write-Warning $psitem.Exception.ErrorRecord + Write-WinUtilLog -Level "ERROR" -Component "Registry" -Message "Registry item not found while changing $Path\$Name`: $($psitem.Exception.Message)" } catch [System.UnauthorizedAccessException] { Write-Warning $psitem.Exception.Message + Write-WinUtilLog -Level "ERROR" -Component "Registry" -Message "Unauthorized while changing $Path\$Name`: $($psitem.Exception.Message)" } catch { Write-Warning "Unable to set $Name due to unhandled exception." Write-Warning $psitem.Exception.StackTrace + Write-WinUtilLog -Level "ERROR" -Component "Registry" -Message "Unhandled exception while changing $Path\$Name`: $($psitem.Exception.Message)" } } diff --git a/functions/private/Set-WinUtilService.ps1 b/functions/private/Set-WinUtilService.ps1 index 3ef8bd06..8be5525e 100644 --- a/functions/private/Set-WinUtilService.ps1 +++ b/functions/private/Set-WinUtilService.ps1 @@ -20,21 +20,33 @@ Function Set-WinUtilService { ) try { Write-Host "Setting Service $Name to $StartupType" + Write-WinUtilLog -Component "Service" -Message "Setting service $Name startup type to $StartupType" # Check if the service exists $service = Get-Service -Name $Name -ErrorAction Stop + if (($service.PSObject.Properties.Name -contains "StartType") -and ([string]$service.StartType -eq [string]$StartupType) ) { + Write-Host "Service $Name is already set to $StartupType" + Write-WinUtilLog -Component "Service" -Message "Service $Name startup type is already $StartupType; no change needed." + return + } + # Service exists, proceed with changing properties -- while handling auto delayed start for PWSH 5 if (($PSVersionTable.PSVersion.Major -lt 7) -and ($StartupType -eq "AutomaticDelayedStart")) { sc.exe config $Name start=delayed-auto } else { $service | Set-Service -StartupType $StartupType -ErrorAction Stop } - } catch [System.ServiceProcess.ServiceNotFoundException] { - Write-Warning "Service $Name was not found." + Write-WinUtilLog -Component "Service" -Message "Service $Name startup type set to $StartupType" } catch { - Write-Warning "Unable to set $Name due to unhandled exception." - Write-Warning $_.Exception.Message + if ($_.FullyQualifiedErrorId -like "NoServiceFoundForGivenName,*") { + Write-Warning "Service $Name was not found." + Write-WinUtilLog -Level "WARN" -Component "Service" -Message "Service $Name was not found." + } else { + Write-Warning "Unable to set $Name due to unhandled exception." + Write-Warning $_.Exception.Message + Write-WinUtilLog -Level "ERROR" -Component "Service" -Message "Unable to set service $Name to $StartupType`: $($_.Exception.Message)" + } } } diff --git a/functions/private/Show-WinUtilMessage.ps1 b/functions/private/Show-WinUtilMessage.ps1 new file mode 100644 index 00000000..f52256ab --- /dev/null +++ b/functions/private/Show-WinUtilMessage.ps1 @@ -0,0 +1,14 @@ +function Show-WinUtilMessage { + <# + .SYNOPSIS + Shows a WinUtil message box and returns the selected result. + #> + param ( + [string]$Message, + [string]$Title = "Winutil", + $Button = "OK", + $Icon = "Information" + ) + + [System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon) +} diff --git a/functions/private/Write-WinUtilLog.ps1 b/functions/private/Write-WinUtilLog.ps1 new file mode 100644 index 00000000..da9e13ab --- /dev/null +++ b/functions/private/Write-WinUtilLog.ps1 @@ -0,0 +1,81 @@ +function Write-WinUtilLog { + <# + + .SYNOPSIS + Writes a timestamped WinUtil log entry to the active session log. + + .PARAMETER Message + The message to write. + + .PARAMETER Level + The severity level for the log entry. + + .PARAMETER Component + The WinUtil component producing the log entry. + + #> + param ( + [Parameter(Mandatory = $true)] + [string]$Message, + + [ValidateSet("INFO", "WARN", "ERROR", "DEBUG")] + [string]$Level = "INFO", + + [string]$Component = "WinUtil" + ) + + try { + $logPath = $null + $transcriptPath = $null + if ($null -ne $sync -and $sync.ContainsKey("logPath")) { + $logPath = $sync.logPath + } + + if ($null -ne $sync -and $sync.ContainsKey("transcriptPath")) { + $transcriptPath = $sync.transcriptPath + } + + if ([string]::IsNullOrWhiteSpace($logPath) -and -not [string]::IsNullOrWhiteSpace($transcriptPath)) { + $logPath = $transcriptPath + } + + if ([string]::IsNullOrWhiteSpace($logPath) -and $null -ne $sync -and $sync.ContainsKey("winutildir")) { + $logDirectory = Join-Path $sync.winutildir "logs" + $logPath = Join-Path $logDirectory "winutil_$(Get-Date -Format "yyyy-MM-dd_HH-mm-ss").log" + $sync.logPath = $logPath + } + + if ([string]::IsNullOrWhiteSpace($logPath) -and -not [string]::IsNullOrWhiteSpace($env:LocalAppData)) { + if ([string]::IsNullOrWhiteSpace($script:WinUtilLogPath)) { + $logDirectory = Join-Path (Join-Path $env:LocalAppData "winutil") "logs" + $script:WinUtilLogPath = Join-Path $logDirectory "winutil_$(Get-Date -Format "yyyy-MM-dd_HH-mm-ss").log" + } + $logPath = $script:WinUtilLogPath + } + + if ([string]::IsNullOrWhiteSpace($logPath)) { + return + } + + $logDirectory = Split-Path -Path $logPath -Parent + if (-not (Test-Path $logDirectory)) { + New-Item -Path $logDirectory -ItemType Directory -Force | Out-Null + } + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + $line = "[$timestamp] [$Level] [$Component] $Message" + + if (-not [string]::IsNullOrWhiteSpace($transcriptPath) -and $logPath -eq $transcriptPath) { + Write-Host $line + return + } + + try { + Add-Content -Path $logPath -Value $line -Encoding UTF8 -ErrorAction Stop + } catch [System.IO.IOException] { + Write-Host $line + } + } catch { + Write-Warning "Unable to write WinUtil log entry: $($_.Exception.Message)" + } +} diff --git a/functions/public/Invoke-WPFAppxRemoval.ps1 b/functions/public/Invoke-WPFAppxRemoval.ps1 index d192c375..074a30df 100644 --- a/functions/public/Invoke-WPFAppxRemoval.ps1 +++ b/functions/public/Invoke-WPFAppxRemoval.ps1 @@ -1,6 +1,6 @@ function Invoke-WPFAppxRemoval { - if (-not ($sync.selectedAppx)) { - [System.Windows.Forms.MessageBox]::Show("No AppX Package selected","Error","OK","Error") + if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) { + Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error" return } @@ -11,26 +11,32 @@ function Invoke-WPFAppxRemoval { param($selected, $apps) $sync.ProcessRunning = $true + Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $(@($selected).Count) selected package(s)." foreach ($key in $selected) { if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") { # Making sure Game Bar isn't running + Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay." Stop-Process -Name GameBarFTServer # This stops annoying ms-gamebar popup when launching games. + Write-WinUtilLog -Component "AppX" -Message "Disabling Game DVR capture before removing Xbox Gaming Overlay." Set-ItemProperty -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR -Name AppCaptureEnabled -Value 0 } if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") { # i hope your having fun reading this + Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad." Stop-Process -Name dllhost } Write-Host "Removing $($apps[$key].Content)" + Write-WinUtilLog -Component "AppX" -Message "Removing $($apps[$key].Content) ($($apps[$key].PackageId))." Get-AppxPackage -Name $apps[$key].PackageId -AllUsers | Remove-AppxPackage -AllUsers if ($key -eq "WPFAppxMSTeams") { # Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office + Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package." Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force } } @@ -38,6 +44,7 @@ function Invoke-WPFAppxRemoval { Write-Host "=================================" Write-Host "-- AppX Removal Finished ---" Write-Host "=================================" + Write-WinUtilLog -Component "AppX" -Message "AppX removal finished." $sync.ProcessRunning = $false } diff --git a/functions/public/Invoke-WPFInstall.ps1 b/functions/public/Invoke-WPFInstall.ps1 index 3d052e31..d7a70cb2 100644 --- a/functions/public/Invoke-WPFInstall.ps1 +++ b/functions/public/Invoke-WPFInstall.ps1 @@ -9,17 +9,20 @@ function Invoke-WPFInstall { if($sync.ProcessRunning) { $msg = "[Invoke-WPFInstall] An Install process is currently running." - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + Show-WinUtilMessage -Message $msg -Title "Winutil" -Button "OK" -Icon "Warning" return } if ($PackagesToInstall.Count -eq 0) { $WarningMsg = "Please select the program(s) to install or upgrade." - [System.Windows.MessageBox]::Show($WarningMsg, $AppTitle, [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + Show-WinUtilMessage -Message $WarningMsg -Title $AppTitle -Button "OK" -Icon "Warning" return } $ManagerPreference = $sync.preferences.packagemanager + Write-WinUtilLog -Component "Install" -Message "Install requested for $(@($PackagesToInstall).Count) selected package(s) using preference: $ManagerPreference" + $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToInstall -Preference $ManagerPreference + Write-WinUtilLog -Component "Install" -Message "Install selected package(s): $($packageSummary -join '; ')" $handle = Invoke-WPFRunspace -ParameterList @(("PackagesToInstall", $PackagesToInstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock { param($PackagesToInstall, $ManagerPreference) @@ -28,6 +31,7 @@ function Invoke-WPFInstall { $packagesWinget = $packagesSorted[[PackageManagers]::Winget] $packagesChoco = $packagesSorted[[PackageManagers]::Choco] + Write-WinUtilLog -Component "Install" -Message "Install package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)" try { $sync.ProcessRunning = $true @@ -44,13 +48,17 @@ function Invoke-WPFInstall { Write-Host "===========================================" Write-Host "-- Installs have finished ---" Write-Host "===========================================" + Write-WinUtilLog -Component "Install" -Message "Install workflow completed." Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } } catch { + Hide-WPFInstallAppBusy Write-Host "===========================================" Write-Host "Error: $_" Write-Host "===========================================" + Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "Install workflow failed: $($_.Exception.Message)" Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } + } finally { + $sync.ProcessRunning = $False } - $sync.ProcessRunning = $False } } diff --git a/functions/public/Invoke-WPFRunspace.ps1 b/functions/public/Invoke-WPFRunspace.ps1 index bccdd5df..03e05038 100644 --- a/functions/public/Invoke-WPFRunspace.ps1 +++ b/functions/public/Invoke-WPFRunspace.ps1 @@ -34,11 +34,11 @@ function Invoke-WPFRunspace { $script:powershell = [powershell]::Create() # Add Scriptblock and Arguments to runspace - $script:powershell.AddScript($ScriptBlock) - $script:powershell.AddArgument($ArgumentList) + [void]$script:powershell.AddScript($ScriptBlock) + [void]$script:powershell.AddArgument($ArgumentList) foreach ($parameter in $ParameterList) { - $script:powershell.AddParameter($parameter[0], $parameter[1]) + [void]$script:powershell.AddParameter($parameter[0], $parameter[1]) } $script:powershell.RunspacePool = $sync.runspace diff --git a/functions/public/Invoke-WPFUnInstall.ps1 b/functions/public/Invoke-WPFUnInstall.ps1 index 4df18cb0..25ba23e7 100644 --- a/functions/public/Invoke-WPFUnInstall.ps1 +++ b/functions/public/Invoke-WPFUnInstall.ps1 @@ -11,26 +11,29 @@ function Invoke-WPFUnInstall { if($sync.ProcessRunning) { $msg = "[Invoke-WPFUnInstall] Install process is currently running" - [System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + Show-WinUtilMessage -Message $msg -Title "Winutil" -Button "OK" -Icon "Warning" return } if ($PackagesToUninstall.Count -eq 0) { $WarningMsg = "Please select the program(s) to uninstall" - [System.Windows.MessageBox]::Show($WarningMsg, $AppTitle, [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning) + Show-WinUtilMessage -Message $WarningMsg -Title $AppTitle -Button "OK" -Icon "Warning" return } - $ButtonType = [System.Windows.MessageBoxButton]::YesNo + $ButtonType = "YesNo" $MessageboxTitle = "Are you sure?" $Messageboxbody = ("This will uninstall the following applications: `n $($PackagesToUninstall | Select-Object Name, Description| Out-String)") - $MessageIcon = [System.Windows.MessageBoxImage]::Information + $MessageIcon = "Information" - $confirm = [System.Windows.MessageBox]::Show($Messageboxbody, $MessageboxTitle, $ButtonType, $MessageIcon) + $confirm = Show-WinUtilMessage -Message $Messageboxbody -Title $MessageboxTitle -Button $ButtonType -Icon $MessageIcon if($confirm -eq "No") {return} $ManagerPreference = $sync.preferences.packagemanager + Write-WinUtilLog -Component "Uninstall" -Message "Uninstall requested for $(@($PackagesToUninstall).Count) selected package(s) using preference: $ManagerPreference" + $packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToUninstall -Preference $ManagerPreference + Write-WinUtilLog -Component "Uninstall" -Message "Uninstall selected package(s): $($packageSummary -join '; ')" Invoke-WPFRunspace -ParameterList @(("PackagesToUninstall", $PackagesToUninstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock { param($PackagesToUninstall, $ManagerPreference) @@ -38,6 +41,7 @@ function Invoke-WPFUnInstall { $packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToUninstall -Preference $ManagerPreference $packagesWinget = $packagesSorted[[PackageManagers]::Winget] $packagesChoco = $packagesSorted[[PackageManagers]::Choco] + Write-WinUtilLog -Component "Uninstall" -Message "Uninstall package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)" try { $sync.ProcessRunning = $true @@ -58,14 +62,18 @@ function Invoke-WPFUnInstall { Write-Host "===========================================" Write-Host "-- Uninstalls have finished ---" Write-Host "===========================================" + Write-WinUtilLog -Component "Uninstall" -Message "Uninstall workflow completed." Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" } } catch { + Hide-WPFInstallAppBusy Write-Host "===========================================" Write-Host "Error: $_" Write-Host "===========================================" + Write-WinUtilLog -Level "ERROR" -Component "Uninstall" -Message "Uninstall workflow failed: $($_.Exception.Message)" Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" } + } finally { + $sync.ProcessRunning = $False } - $sync.ProcessRunning = $False } } diff --git a/functions/public/Invoke-WPFUpdatesdefault.ps1 b/functions/public/Invoke-WPFUpdatesdefault.ps1 index 7e280c32..dcd88982 100644 --- a/functions/public/Invoke-WPFUpdatesdefault.ps1 +++ b/functions/public/Invoke-WPFUpdatesdefault.ps1 @@ -6,8 +6,10 @@ function Invoke-WPFUpdatesdefault { #> $ErrorActionPreference = 'SilentlyContinue' + Write-WinUtilLog -Component "Updates" -Message "Resetting Windows Update settings to default." Write-Host "Removing Windows Update policy settings..." -ForegroundColor Green + Write-WinUtilLog -Component "Updates" -Message "Removing Windows Update policy registry paths." Remove-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -Recurse -Force Remove-Item -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\DeliveryOptimization" -Recurse -Force @@ -17,24 +19,31 @@ function Invoke-WPFUpdatesdefault { Remove-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -Recurse -Force Write-Host "Showing Windows Updates in settings..." + Write-WinUtilLog -Component "Updates" -Message "Showing Windows Update settings page." Remove-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer -Name SettingsPageVisibility Write-Host "Reenabling Windows Update Services..." -ForegroundColor Green + Write-WinUtilLog -Component "Updates" -Message "Restoring Windows Update service startup types." Write-Host "Restored BITS to Manual." + Write-WinUtilLog -Component "Updates" -Message "Restoring BITS service to Manual." Set-Service -Name BITS -StartupType Manual Write-Host "Restored wuauserv to Manual." + Write-WinUtilLog -Component "Updates" -Message "Restoring wuauserv service to Manual." Set-Service -Name wuauserv -StartupType Manual Write-Host "Restored UsoSvc to Automatic." + Write-WinUtilLog -Component "Updates" -Message "Starting UsoSvc service and restoring startup type to Automatic." Start-Service -Name UsoSvc Set-Service -Name UsoSvc -StartupType Automatic Write-Host "Restored WaaSMedicSvc to Manual." + Write-WinUtilLog -Component "Updates" -Message "Restoring WaaSMedicSvc service to Manual." Set-Service -Name WaaSMedicSvc -StartupType Manual Write-Host "Enabling update related scheduled tasks..." -ForegroundColor Green + Write-WinUtilLog -Component "Updates" -Message "Enabling update related scheduled tasks." $Tasks = '\Microsoft\Windows\InstallService\*', @@ -49,6 +58,7 @@ function Invoke-WPFUpdatesdefault { } Write-Host "Windows Local Policies Reset to Default." + Write-WinUtilLog -Component "Updates" -Message "Resetting local security policy to defaults with secedit." secedit /configure /cfg "$Env:SystemRoot\inf\defltbase.inf" /db defltbase.sdb Write-Host "===================================================" -ForegroundColor Green @@ -56,4 +66,5 @@ function Invoke-WPFUpdatesdefault { Write-Host "===================================================" -ForegroundColor Green Write-Host "Note: You must restart your system in order for all changes to take effect." -ForegroundColor Yellow + Write-WinUtilLog -Component "Updates" -Message "Windows Update default workflow completed. Restart required." } diff --git a/functions/public/Invoke-WPFUpdatesdisable.ps1 b/functions/public/Invoke-WPFUpdatesdisable.ps1 index 6a65f76e..d020d9b6 100644 --- a/functions/public/Invoke-WPFUpdatesdisable.ps1 +++ b/functions/public/Invoke-WPFUpdatesdisable.ps1 @@ -9,8 +9,10 @@ function Invoke-WPFUpdatesdisable { #> $ErrorActionPreference = 'SilentlyContinue' + Write-WinUtilLog -Component "Updates" -Message "Disabling Windows Update settings." Write-Host "Configuring registry settings..." -ForegroundColor Yellow + Write-WinUtilLog -Component "Updates" -Message "Configuring Windows Update registry policy values for disable mode." New-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -Force Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -Name "NoAutoUpdate" -Type DWord -Value 1 @@ -20,22 +22,28 @@ function Invoke-WPFUpdatesdisable { Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\DeliveryOptimization\Config" -Name "DODownloadMode" -Type DWord -Value 0 Write-Host "Hiding Windows Updates from settings..." + Write-WinUtilLog -Component "Updates" -Message "Hiding Windows Update settings page." Set-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer -Name SettingsPageVisibility -Value hide:windowsupdate Write-Host "Disabled BITS Service." + Write-WinUtilLog -Component "Updates" -Message "Disabling BITS service." Set-Service -Name BITS -StartupType Disabled Write-Host "Disabled wuauserv Service." + Write-WinUtilLog -Component "Updates" -Message "Disabling wuauserv service." Set-Service -Name wuauserv -StartupType Disabled Write-Host "Disabled UsoSvc Service." + Write-WinUtilLog -Component "Updates" -Message "Stopping and disabling UsoSvc service." Stop-Service -Name UsoSvc -Force Set-Service -Name UsoSvc -StartupType Disabled Remove-Item "C:\Windows\SoftwareDistribution\*" -Recurse -Force Write-Host "Cleared SoftwareDistribution folder." + Write-WinUtilLog -Component "Updates" -Message "Cleared SoftwareDistribution folder." Write-Host "Disabling update related scheduled tasks..." -ForegroundColor Yellow + Write-WinUtilLog -Component "Updates" -Message "Disabling update related scheduled tasks." $Tasks = '\Microsoft\Windows\InstallService\*', @@ -54,4 +62,5 @@ function Invoke-WPFUpdatesdisable { Write-Host "=================================" -ForegroundColor Green Write-Host "Note: You must restart your system in order for all changes to take effect." -ForegroundColor Yellow + Write-WinUtilLog -Component "Updates" -Message "Windows Update disable workflow completed. Restart required." } diff --git a/functions/public/Invoke-WPFUpdatessecurity.ps1 b/functions/public/Invoke-WPFUpdatessecurity.ps1 index 97ef2688..5c5e97c6 100644 --- a/functions/public/Invoke-WPFUpdatessecurity.ps1 +++ b/functions/public/Invoke-WPFUpdatessecurity.ps1 @@ -14,6 +14,8 @@ function Invoke-WPFUpdatessecurity { #> Write-Host "Disabling driver offering through Windows Update..." + Write-WinUtilLog -Component "Updates" -Message "Applying recommended Windows Update settings." + Write-WinUtilLog -Component "Updates" -Message "Disabling driver offering through Windows Update." New-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Device Metadata" -Force Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Device Metadata" -Name "PreventDeviceMetadataFromNetwork" -Type DWord -Value 1 @@ -28,6 +30,7 @@ function Invoke-WPFUpdatessecurity { Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -Name "ExcludeWUDriversInQualityUpdate" -Type DWord -Value 1 Write-Host "Setting cumulative updates back by 1 year and security updates by 4 days..." + Write-WinUtilLog -Component "Updates" -Message "Deferring feature updates by 365 days and quality updates by 4 days." New-Item -Path "HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" -Force @@ -36,6 +39,7 @@ function Invoke-WPFUpdatessecurity { Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" -Name "DeferQualityUpdatesPeriodInDays" -Type DWord -Value 4 Write-Host "Disabling Windows Update automatic restart..." + Write-WinUtilLog -Component "Updates" -Message "Disabling Windows Update automatic restart while users are logged in." New-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -Force Set-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -Name "NoAutoRebootWithLoggedOnUsers" -Type DWord -Value 1 @@ -44,4 +48,5 @@ function Invoke-WPFUpdatessecurity { Write-Host "=================================" Write-Host "-- Updates Set to Recommended ---" Write-Host "=================================" + Write-WinUtilLog -Component "Updates" -Message "Recommended Windows Update settings workflow completed." } diff --git a/functions/public/Invoke-WPFtweaksbutton.ps1 b/functions/public/Invoke-WPFtweaksbutton.ps1 index 1c413a66..f6186c40 100644 --- a/functions/public/Invoke-WPFtweaksbutton.ps1 +++ b/functions/public/Invoke-WPFtweaksbutton.ps1 @@ -22,6 +22,7 @@ function Invoke-WPFtweaksbutton { $tweaksToRun = @($Tweaks | Where-Object { $_ -ne $restorePointTweak }) $totalSteps = [Math]::Max($Tweaks.Count, 1) $completedSteps = 0 + Write-WinUtilLog -Component "Tweaks" -Message "Tweaks requested: $(@($Tweaks).Count) selected tweak(s), DNS provider: $dnsProvider" if ($tweaks.count -eq 0 -and $dnsProvider -eq "Default") { $msg = "Please check the tweaks you wish to perform." @@ -39,6 +40,7 @@ function Invoke-WPFtweaksbutton { } Set-WinUtilProgressBar -Label "Creating restore point" -Percent 0 + Write-WinUtilLog -Component "Tweaks" -Message "Creating restore point before applying selected tweaks." Invoke-WinUtilTweaks $restorePointTweak $completedSteps = 1 @@ -49,6 +51,7 @@ function Invoke-WPFtweaksbutton { Write-Host "=================================" Write-Host "-- Tweaks are Finished ---" Write-Host "=================================" + Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed after restore point." return } } @@ -82,5 +85,6 @@ function Invoke-WPFtweaksbutton { Write-Host "=================================" Write-Host "-- Tweaks are Finished ---" Write-Host "=================================" + Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed." } } diff --git a/functions/public/Invoke-WPFundoall.ps1 b/functions/public/Invoke-WPFundoall.ps1 index 6bde6d13..230dce52 100644 --- a/functions/public/Invoke-WPFundoall.ps1 +++ b/functions/public/Invoke-WPFundoall.ps1 @@ -24,6 +24,7 @@ function Invoke-WPFundoall { param($tweaks) $sync.ProcessRunning = $true + Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks requested: $(@($tweaks).Count) selected tweak(s)." if ($tweaks.count -eq 1) { Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" } } else { @@ -43,6 +44,7 @@ function Invoke-WPFundoall { Write-Host "==================================" Write-Host "--- Undo Tweaks are Finished ---" Write-Host "==================================" + Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks workflow completed." } } diff --git a/pester/appx.Tests.ps1 b/pester/appx.Tests.ps1 new file mode 100644 index 00000000..d1b459f9 --- /dev/null +++ b/pester/appx.Tests.ps1 @@ -0,0 +1,259 @@ +#=========================================================================== +# Tests - AppX Removal +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Remove-WinUtilAPPX.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxRemoval.ps1") + + function Write-WinUtilLog { + param($Message, $Level, $Component) + } + function Show-WinUtilMessage { + param($Message, $Title, $Button, $Icon) + } + function Invoke-WPFRunspace { + param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) + } + function Get-AppxPackage { + param($Name, [switch]$AllUsers) + } + function Remove-AppxPackage { + param( + [Parameter(ValueFromPipeline = $true)] + $InputObject, + [switch]$AllUsers + ) + process { } + } + function Get-AppxProvisionedPackage { + param([switch]$Online) + } + function Remove-AppxProvisionedPackage { + param( + [Parameter(ValueFromPipeline = $true)] + $InputObject, + [switch]$Online + ) + process { } + } + function Get-Package { + param($Name, $ErrorAction) + } + function Uninstall-Package { + param( + [Parameter(ValueFromPipeline = $true)] + $InputObject, + [switch]$Force + ) + process { } + } +} + +Describe "Remove-WinUtilAPPX" { + BeforeEach { + Mock Write-Host { } + Mock Write-WinUtilLog { } + Mock Get-AppxPackage { + [pscustomobject]@{ + Name = $Name + } + } + Mock Remove-AppxPackage { } + Mock Get-AppxProvisionedPackage { + @( + [pscustomobject]@{ DisplayName = "Microsoft.XboxGamingOverlay" } + [pscustomobject]@{ DisplayName = "Microsoft.WindowsCalculator" } + ) + } + Mock Remove-AppxProvisionedPackage { } + } + + It "removes matching installed and provisioned AppX packages" { + Remove-WinUtilAPPX -Name "Microsoft.Xbox*" + + Should -Invoke -CommandName Get-AppxPackage -Times 1 -Exactly -ParameterFilter { + $Name -eq "Microsoft.Xbox*" -and $AllUsers -eq $true + } + Should -Invoke -CommandName Remove-AppxPackage -Times 1 -Exactly -ParameterFilter { + $InputObject.Name -eq "Microsoft.Xbox*" -and $AllUsers -eq $true + } + Should -Invoke -CommandName Get-AppxProvisionedPackage -Times 1 -Exactly -ParameterFilter { + $Online -eq $true + } + Should -Invoke -CommandName Remove-AppxProvisionedPackage -Times 1 -Exactly -ParameterFilter { + $InputObject.DisplayName -eq "Microsoft.XboxGamingOverlay" -and $Online -eq $true + } + } +} + +Describe "Invoke-WPFAppxRemoval entrypoint" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ProcessRunning = $false + selectedAppx = [System.Collections.Generic.List[string]]::new() + configs = @{ + appxHashtable = @{} + } + }) + $script:capturedAppxScriptBlock = $null + $script:capturedAppxParameterList = $null + + Mock Show-WinUtilMessage { "OK" } + Mock Invoke-WPFRunspace { + $script:capturedAppxScriptBlock = $ScriptBlock + $script:capturedAppxParameterList = $ParameterList + [pscustomobject]@{ MockHandle = $true } + } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedAppxParameterList -Scope Script -ErrorAction SilentlyContinue + } + + It "prompts and exits when no AppX packages are selected" { + Invoke-WPFAppxRemoval + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "No AppX Package selected" -and + $Title -eq "Error" -and + $Button -eq "OK" -and + $Icon -eq "Error" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + } + + It "passes selected AppX keys and app metadata to the removal runspace" { + $script:sync.selectedAppx.Add("WPFAppxExample") + $script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{ + Content = "Example App" + PackageId = "Example.Package" + } + + Invoke-WPFAppxRemoval + + Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { + $ScriptBlock -is [scriptblock] -and + $ParameterList.Count -eq 2 -and + $ParameterList[0][0] -eq "selected" -and + $ParameterList[0][1][0] -eq "WPFAppxExample" -and + $ParameterList[1][0] -eq "apps" -and + $ParameterList[1][1]["WPFAppxExample"].PackageId -eq "Example.Package" + } + } +} + +Describe "Invoke-WPFAppxRemoval runspace body" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ProcessRunning = $false + selectedAppx = [System.Collections.Generic.List[string]]::new() + configs = @{ + appxHashtable = @{} + } + }) + $script:capturedAppxScriptBlock = $null + $script:apps = @{ + WPFAppxExample = [pscustomobject]@{ + Content = "Example App" + PackageId = "Example.Package" + } + WPFAppxMicrosoft_XboxGamingOverlay = [pscustomobject]@{ + Content = "Xbox Gaming Overlay" + PackageId = "Microsoft.XboxGamingOverlay" + } + WPFAppxMicrosoft_WindowsNotepad = [pscustomobject]@{ + Content = "Notepad" + PackageId = "Microsoft.WindowsNotepad" + } + WPFAppxMSTeams = [pscustomobject]@{ + Content = "Microsoft Teams" + PackageId = "MSTeams" + } + } + + Mock Invoke-WPFRunspace { + $script:capturedAppxScriptBlock = $ScriptBlock + [pscustomobject]@{ MockHandle = $true } + } + Mock Show-WinUtilMessage { "OK" } + Mock Write-Host { } + Mock Write-WinUtilLog { } + Mock Stop-Process { } + Mock Set-ItemProperty { } + Mock Get-AppxPackage { + [pscustomobject]@{ + Name = $Name + } + } + Mock Remove-AppxPackage { } + Mock Get-Package { + [pscustomobject]@{ + Name = "Microsoft Teams Meeting Add-in" + } + } + Mock Uninstall-Package { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name apps -Scope Script -ErrorAction SilentlyContinue + } + + It "removes selected AppX packages and clears ProcessRunning when finished" { + $selected = @("WPFAppxExample") + $script:sync.selectedAppx.Add("WPFAppxExample") + $script:sync.configs.appxHashtable = $script:apps + + Invoke-WPFAppxRemoval + & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + + Should -Invoke -CommandName Get-AppxPackage -Times 1 -Exactly -ParameterFilter { + $Name -eq "Example.Package" -and $AllUsers -eq $true + } + Should -Invoke -CommandName Remove-AppxPackage -Times 1 -Exactly -ParameterFilter { + $InputObject.Name -eq "Example.Package" -and $AllUsers -eq $true + } + $script:sync.ProcessRunning | Should -BeFalse + } + + It "applies special cleanup for Xbox overlay, Notepad, and Teams selections" { + $selected = @( + "WPFAppxMicrosoft_XboxGamingOverlay", + "WPFAppxMicrosoft_WindowsNotepad", + "WPFAppxMSTeams" + ) + foreach ($key in $selected) { + $script:sync.selectedAppx.Add($key) + } + $script:sync.configs.appxHashtable = $script:apps + + Invoke-WPFAppxRemoval + & $script:capturedAppxScriptBlock -selected $selected -apps $script:apps + + Should -Invoke -CommandName Stop-Process -Times 1 -Exactly -ParameterFilter { + $Name -eq "GameBarFTServer" + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR" -and + $Name -eq "AppCaptureEnabled" -and + $Value -eq 0 + } + Should -Invoke -CommandName Stop-Process -Times 1 -Exactly -ParameterFilter { + $Name -eq "dllhost" + } + Should -Invoke -CommandName Get-Package -Times 1 -Exactly -ParameterFilter { + $Name -eq "Microsoft Teams*" + } + Should -Invoke -CommandName Uninstall-Package -Times 1 -Exactly -ParameterFilter { + $InputObject.Name -eq "Microsoft Teams Meeting Add-in" -and $Force -eq $true + } + Should -Invoke -CommandName Remove-AppxPackage -Times 3 -Exactly + $script:sync.ProcessRunning | Should -BeFalse + } +} diff --git a/pester/configs.Tests.ps1 b/pester/configs.Tests.ps1 index e90f9984..ee321360 100644 --- a/pester/configs.Tests.ps1 +++ b/pester/configs.Tests.ps1 @@ -2,7 +2,12 @@ # Tests - Config Files #=========================================================================== +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $configRoot = Join-Path $PSScriptRoot "..\config" +$functionRoot = Join-Path $repoRoot "functions" +$xamlPath = Join-Path $repoRoot "xaml\inputXML.xaml" +$mainScriptPath = Join-Path $repoRoot "scripts\main.ps1" +$buttonScriptPath = Join-Path $repoRoot "functions\public\Invoke-WPFButton.ps1" $configCases = @( Get-ChildItem -Path $configRoot -Filter *.json | ForEach-Object { @{ @@ -12,6 +17,103 @@ $configCases = @( } ) +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:configRoot = Join-Path $script:repoRoot "config" + $script:functionRoot = Join-Path $script:repoRoot "functions" + $script:xamlPath = Join-Path $script:repoRoot "xaml\inputXML.xaml" + $script:mainScriptPath = Join-Path $script:repoRoot "scripts\main.ps1" + $script:buttonScriptPath = Join-Path $script:repoRoot "functions\public\Invoke-WPFButton.ps1" + +function script:Get-WinUtilConfigObject { + param([string]$Name) + + Get-Content -Path (Join-Path $script:configRoot "$Name.json") -Raw | ConvertFrom-Json +} + +function script:Test-WinUtilHasProperty { + param( + [Parameter(Mandatory)] + [psobject]$Object, + + [Parameter(Mandatory)] + [string]$Name + ) + + return @($Object.PSObject.Properties.Name) -contains $Name +} + +function script:Test-WinUtilHasNonEmptyProperty { + param( + [Parameter(Mandatory)] + [psobject]$Object, + + [Parameter(Mandatory)] + [string]$Name + ) + + if (-not (Test-WinUtilHasProperty -Object $Object -Name $Name)) { + return $false + } + + $value = $Object.$Name + if ($null -eq $value) { + return $false + } + + if ($value -is [string]) { + return -not [string]::IsNullOrWhiteSpace($value) + } + + if ($value -is [array]) { + return @($value).Count -gt 0 + } + + return -not [string]::IsNullOrWhiteSpace([string]$value) +} + +function script:Get-WinUtilMissingRequiredFields { + param( + [Parameter(Mandatory)] + [string]$EntryName, + + [Parameter(Mandatory)] + [psobject]$Entry, + + [Parameter(Mandatory)] + [string[]]$RequiredFields + ) + + foreach ($field in $RequiredFields) { + if (-not (Test-WinUtilHasNonEmptyProperty -Object $Entry -Name $field)) { + "$EntryName missing $field" + } + } +} + +function script:Get-WinUtilTopLevelFunctionNames { + Get-ChildItem -Path $script:functionRoot -Filter *.ps1 -Recurse | ForEach-Object { + $tokens = $null + $syntaxErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$syntaxErrors) + if ($syntaxErrors.Count -ne 0) { + throw ($syntaxErrors | Out-String) + } + + $ast.EndBlock.Statements | + Where-Object { $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] } | + ForEach-Object { $_.Name } + } | Sort-Object -Unique +} + +function script:Get-WinUtilButtonSwitchNames { + $buttonSource = Get-Content -Path $script:buttonScriptPath -Raw + [regex]::Matches($buttonSource, '"(WPF[A-Za-z0-9_]+)"\s*\{') | + ForEach-Object { $_.Groups[1].Value } | + Sort-Object -Unique +} +} + Describe "Config files" { foreach ($configCase in $configCases) { It "imports $($configCase.Name) with no JSON errors" -TestCases $configCase { @@ -108,3 +210,341 @@ Describe "Tweaks config" { } } } + +Describe "Preset config" { + It "references existing config entries or supported actions" { + $preset = Get-WinUtilConfigObject -Name "preset" + $applications = Get-WinUtilConfigObject -Name "applications" + $tweaks = Get-WinUtilConfigObject -Name "tweaks" + $feature = Get-WinUtilConfigObject -Name "feature" + $appx = Get-WinUtilConfigObject -Name "appx" + + $validReferences = @( + $tweaks.PSObject.Properties.Name + $feature.PSObject.Properties.Name + $appx.PSObject.Properties.Name + $applications.PSObject.Properties.Name | ForEach-Object { "WPFInstall$_" } + Get-WinUtilButtonSwitchNames + ) | Sort-Object -Unique + + $invalidReferences = New-Object System.Collections.Generic.List[string] + foreach ($presetEntry in $preset.PSObject.Properties) { + foreach ($reference in @($presetEntry.Value)) { + if ($validReferences -notcontains $reference) { + $invalidReferences.Add("$($presetEntry.Name) references missing item $reference") + } + } + } + + if ($invalidReferences.Count -gt 0) { + throw ($invalidReferences -join "`n") + } + } +} + +Describe "App navigation config" { + It "is wired to an existing XAML target grid" { + $mainScript = Get-Content -Path $script:mainScriptPath -Raw + $targetGridMatch = [regex]::Match( + $mainScript, + 'Invoke-WPFUIElements\s+-configVariable\s+\$sync\.configs\.appnavigation\s+-targetGridName\s+"([^"]+)"' + ) + + if (-not $targetGridMatch.Success) { + throw "scripts/main.ps1 does not wire appnavigation through Invoke-WPFUIElements." + } + + $xamlText = Get-Content -Path $script:xamlPath -Raw + $targetGridName = $targetGridMatch.Groups[1].Value + if ($xamlText -notmatch "Name=`"$([regex]::Escape($targetGridName))`"") { + throw "appnavigation target grid '$targetGridName' was not found in xaml/inputXML.xaml." + } + } + + It "contains renderable entries with valid button and radio groups" { + $appnavigation = Get-WinUtilConfigObject -Name "appnavigation" + $feature = Get-WinUtilConfigObject -Name "feature" + $requiredFields = @("Content", "Category", "Type", "Order", "Description") + $allowedTypes = @("Button", "RadioButton", "Note") + $supportedButtons = @( + Get-WinUtilButtonSwitchNames + $feature.PSObject.Properties.Name + ) | Sort-Object -Unique + $invalidEntries = New-Object System.Collections.Generic.List[string] + + foreach ($entry in $appnavigation.PSObject.Properties) { + foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName $entry.Name -Entry $entry.Value -RequiredFields $requiredFields)) { + $invalidEntries.Add($missingField) + } + + if ($allowedTypes -notcontains $entry.Value.Type) { + $invalidEntries.Add("$($entry.Name) has unsupported Type '$($entry.Value.Type)'") + } + + if ($entry.Value.Type -eq "Button" -and $supportedButtons -notcontains $entry.Name) { + $invalidEntries.Add("$($entry.Name) is not handled by Invoke-WPFButton or feature config") + } + + if ($entry.Value.Type -eq "RadioButton") { + if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "GroupName")) { + $invalidEntries.Add("$($entry.Name) missing GroupName") + } + + if (-not (Test-WinUtilHasProperty -Object $entry.Value -Name "Checked")) { + $invalidEntries.Add("$($entry.Name) missing Checked") + } + } + } + + $radioButtons = @($appnavigation.PSObject.Properties | Where-Object { $_.Value.Type -eq "RadioButton" }) + foreach ($group in ($radioButtons | Group-Object -Property { $_.Value.GroupName })) { + if ([string]::IsNullOrWhiteSpace($group.Name)) { + $invalidEntries.Add("RadioButton group name is blank") + continue + } + + $checkedCount = @($group.Group | Where-Object { $_.Value.Checked -eq $true }).Count + if ($checkedCount -ne 1) { + $invalidEntries.Add("RadioButton group '$($group.Name)' has $checkedCount checked entries") + } + } + + if ($invalidEntries.Count -gt 0) { + throw ($invalidEntries -join "`n") + } + } +} + +Describe "UI-rendered config entries" { + It "contains required AppX fields" { + $appx = Get-WinUtilConfigObject -Name "appx" + $requiredFields = @("Category", "Content", "Description", "Panel", "PackageId") + $invalidEntries = New-Object System.Collections.Generic.List[string] + + foreach ($entry in $appx.PSObject.Properties) { + foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName $entry.Name -Entry $entry.Value -RequiredFields $requiredFields)) { + $invalidEntries.Add($missingField) + } + } + + if ($invalidEntries.Count -gt 0) { + throw ($invalidEntries -join "`n") + } + } + + It "contains required DNS fields with parseable IP addresses" { + $dns = Get-WinUtilConfigObject -Name "dns" + $requiredFields = @("Primary", "Secondary", "Primary6", "Secondary6") + $invalidEntries = New-Object System.Collections.Generic.List[string] + + foreach ($entry in $dns.PSObject.Properties) { + foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName $entry.Name -Entry $entry.Value -RequiredFields $requiredFields)) { + $invalidEntries.Add($missingField) + } + + foreach ($field in $requiredFields) { + if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name $field)) { + continue + } + + try { + [System.Net.IPAddress]::Parse([string]$entry.Value.$field) | Out-Null + } catch { + $invalidEntries.Add("$($entry.Name) $field is not a parseable IP address") + } + } + } + + if ($invalidEntries.Count -gt 0) { + throw ($invalidEntries -join "`n") + } + } + + It "contains required feature fields and valid configured functions" { + $feature = Get-WinUtilConfigObject -Name "feature" + $functionNames = Get-WinUtilTopLevelFunctionNames + $requiredFields = @("Content", "category", "panel", "link") + $invalidEntries = New-Object System.Collections.Generic.List[string] + + foreach ($entry in $feature.PSObject.Properties) { + foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName $entry.Name -Entry $entry.Value -RequiredFields $requiredFields)) { + $invalidEntries.Add($missingField) + } + + if ($entry.Value.Type -and $entry.Value.Type -ne "Button") { + $invalidEntries.Add("$($entry.Name) has unsupported Type '$($entry.Value.Type)'") + } + + if ($entry.Value.Type -eq "Button") { + if (-not $entry.Value.function -and -not $entry.Value.InvokeScript) { + $invalidEntries.Add("$($entry.Name) button missing function or InvokeScript") + } + } else { + if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "Description")) { + $invalidEntries.Add("$($entry.Name) missing Description") + } + + if (-not $entry.Value.feature -and -not $entry.Value.InvokeScript) { + $invalidEntries.Add("$($entry.Name) missing feature or InvokeScript action") + } + } + + if ($entry.Value.function -and $functionNames -notcontains $entry.Value.function) { + $invalidEntries.Add("$($entry.Name) references missing function $($entry.Value.function)") + } + } + + if ($invalidEntries.Count -gt 0) { + throw ($invalidEntries -join "`n") + } + } + + It "contains required tweak fields and valid action metadata" { + $tweaks = Get-WinUtilConfigObject -Name "tweaks" + $requiredFields = @("Content", "category", "panel", "link") + $allowedTypes = @("Button", "Combobox", "Toggle", "ToggleButton") + $supportedButtons = Get-WinUtilButtonSwitchNames + $invalidEntries = New-Object System.Collections.Generic.List[string] + + foreach ($entry in $tweaks.PSObject.Properties) { + foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName $entry.Name -Entry $entry.Value -RequiredFields $requiredFields)) { + $invalidEntries.Add($missingField) + } + + if ($entry.Value.Type -and $allowedTypes -notcontains $entry.Value.Type) { + $invalidEntries.Add("$($entry.Name) has unsupported Type '$($entry.Value.Type)'") + } + + if ($entry.Value.Type -eq "Button") { + if ($supportedButtons -notcontains $entry.Name) { + $invalidEntries.Add("$($entry.Name) is not handled by Invoke-WPFButton") + } + } elseif ($entry.Value.Type -eq "Combobox") { + if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "ComboItems")) { + $invalidEntries.Add("$($entry.Name) combobox missing ComboItems") + } + } else { + if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "Description")) { + $invalidEntries.Add("$($entry.Name) missing Description") + } + + if (-not $entry.Value.registry -and -not $entry.Value.service -and -not $entry.Value.InvokeScript -and -not $entry.Value.appx) { + $invalidEntries.Add("$($entry.Name) missing registry, service, InvokeScript, or appx action") + } + } + + foreach ($registryEntry in @($entry.Value.registry)) { + if ($null -eq $registryEntry) { continue } + + foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName "$($entry.Name),registry" -Entry $registryEntry -RequiredFields @("Path", "Name", "Type", "Value", "OriginalValue"))) { + $invalidEntries.Add($missingField) + } + } + + foreach ($serviceEntry in @($entry.Value.service)) { + if ($null -eq $serviceEntry) { continue } + + foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName "$($entry.Name),service" -Entry $serviceEntry -RequiredFields @("Name", "StartupType", "OriginalType"))) { + $invalidEntries.Add($missingField) + } + } + } + + if ($invalidEntries.Count -gt 0) { + throw ($invalidEntries -join "`n") + } + } + + It "defines theme resources required by XAML rendering" { + $themes = Get-WinUtilConfigObject -Name "themes" + $invalidEntries = New-Object System.Collections.Generic.List[string] + + foreach ($themeName in @("shared", "Light", "Dark")) { + if (-not (Test-WinUtilHasProperty -Object $themes -Name $themeName)) { + $invalidEntries.Add("themes.json missing $themeName") + continue + } + + foreach ($property in $themes.$themeName.PSObject.Properties) { + if ([string]::IsNullOrWhiteSpace([string]$property.Value)) { + $invalidEntries.Add("$themeName.$($property.Name) is blank") + } + } + } + + $lightKeys = @($themes.Light.PSObject.Properties.Name) + $darkKeys = @($themes.Dark.PSObject.Properties.Name) + foreach ($key in $lightKeys) { + if ($darkKeys -notcontains $key) { + $invalidEntries.Add("Dark theme missing $key") + } + } + foreach ($key in $darkKeys) { + if ($lightKeys -notcontains $key) { + $invalidEntries.Add("Light theme missing $key") + } + } + + $xamlText = Get-Content -Path $script:xamlPath -Raw + $dynamicResourceNames = @( + [regex]::Matches($xamlText, '\{DynamicResource\s+([^\}\s]+)') | + ForEach-Object { $_.Groups[1].Value } + ) | Sort-Object -Unique + $xamlDefinedResourceNames = @( + [regex]::Matches($xamlText, 'x:Key="([^"]+)"') | + ForEach-Object { $_.Groups[1].Value } + ) | Sort-Object -Unique + $themeResourceNames = @( + $themes.shared.PSObject.Properties.Name + $themes.Light.PSObject.Properties.Name + $themes.Dark.PSObject.Properties.Name + "CBorderColor" + "CButtonBackgroundMouseoverColor" + ) | Sort-Object -Unique + + foreach ($resourceName in $dynamicResourceNames) { + if ($xamlDefinedResourceNames -notcontains $resourceName -and $themeResourceNames -notcontains $resourceName) { + $invalidEntries.Add("XAML DynamicResource '$resourceName' is not defined in themes.json or XAML resources") + } + } + + if ($invalidEntries.Count -gt 0) { + throw ($invalidEntries -join "`n") + } + } +} + +Describe "Embedded config scripts" { + It "parse as PowerShell scriptblocks" { + $invalidScripts = New-Object System.Collections.Generic.List[string] + + foreach ($configFile in (Get-ChildItem -Path $script:configRoot -Filter *.json)) { + $config = Get-Content -Path $configFile.FullName -Raw | ConvertFrom-Json + foreach ($entry in $config.PSObject.Properties) { + foreach ($field in @("InvokeScript", "UndoScript")) { + if (-not (Test-WinUtilHasProperty -Object $entry.Value -Name $field)) { + continue + } + + $index = 0 + foreach ($scriptText in @($entry.Value.$field)) { + $index++ + if ([string]::IsNullOrWhiteSpace([string]$scriptText)) { + continue + } + + try { + [scriptblock]::Create([string]$scriptText) | Out-Null + } catch { + $invalidScripts.Add("$($configFile.Name):$($entry.Name).$field[$index] $($psitem.Exception.Message)") + } + } + } + } + } + + if ($invalidScripts.Count -gt 0) { + throw ($invalidScripts -join "`n") + } + } +} diff --git a/pester/install-workflow.Tests.ps1 b/pester/install-workflow.Tests.ps1 new file mode 100644 index 00000000..f4d633cb --- /dev/null +++ b/pester/install-workflow.Tests.ps1 @@ -0,0 +1,414 @@ +#=========================================================================== +# Tests - Install and Uninstall Workflows +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + if (-not ("PackageManagers" -as [type])) { + Add-Type @" +public enum PackageManagers +{ + Winget, + Choco +} +"@ + } + + . (Join-Path $script:repoRoot "functions\private\Get-WinUtilPackageLogSummary.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFInstall.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFUnInstall.ps1") + + function Show-WinUtilMessage { + param($Message, $Title, $Button, $Icon) + } + function Invoke-WPFRunspace { + param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) + } + function Get-WinUtilSelectedPackages { + param($PackageList, [PackageManagers]$Preference) + } + function Show-WPFInstallAppBusy { + param($text) + } + function Hide-WPFInstallAppBusy { } + function Install-WinUtilWinget { } + function Install-WinUtilChoco { } + function Install-WinUtilProgramWinget { + param($Action, $Programs) + } + function Install-WinUtilProgramChoco { + param($Action, $Programs) + } + function Invoke-WPFUIThread { + param([scriptblock]$ScriptBlock) + } + function Write-WinUtilLog { + param($Message, $Level, $Component) + } + + function script:New-WinUtilPackage { + param( + [string]$Name = "Git", + [string]$Winget = "Git.Git", + [string]$Choco = "git" + ) + + [pscustomobject]@{ + Name = $Name + Description = "$Name package" + winget = $Winget + choco = $Choco + } + } + + function script:New-WinUtilInstallTestContext { + param( + [bool]$ProcessRunning = $false, + [object[]]$Packages = @() + ) + + $applications = @{} + $selectedApps = [System.Collections.Generic.List[string]]::new() + + for ($i = 0; $i -lt $Packages.Count; $i++) { + $key = "WPFInstallTest$i" + $applications[$key] = $Packages[$i] + $selectedApps.Add($key) + } + + $script:AppTitle = "Winutil" + $script:sync = [Hashtable]::Synchronized(@{ + ProcessRunning = $ProcessRunning + selectedApps = $selectedApps + preferences = [pscustomobject]@{ + packagemanager = [PackageManagers]::Winget + } + configs = @{ + applicationsHashtable = $applications + } + }) + } + + function script:New-WinUtilPackageSplit { + param( + [string[]]$Winget = @(), + [string[]]$Choco = @() + ) + + $packages = @{} + $packages[[PackageManagers]::Winget] = [System.Collections.Generic.List[string]]::new() + $packages[[PackageManagers]::Choco] = [System.Collections.Generic.List[string]]::new() + + foreach ($package in $Winget) { + $null = $packages[[PackageManagers]::Winget].Add($package) + } + + foreach ($package in $Choco) { + $null = $packages[[PackageManagers]::Choco].Add($package) + } + + $packages + } +} + +Describe "Invoke-WPFInstall entrypoint" { + BeforeEach { + $script:package = New-WinUtilPackage + New-WinUtilInstallTestContext -Packages @($script:package) + $script:capturedInstallScriptBlock = $null + $script:capturedInstallParameterList = $null + + Mock Show-WinUtilMessage { "OK" } + Mock Invoke-WPFRunspace { + $script:capturedInstallScriptBlock = $ScriptBlock + $script:capturedInstallParameterList = $ParameterList + [pscustomobject]@{ MockHandle = $true } + } + Mock Write-WinUtilLog { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name AppTitle -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedInstallParameterList -Scope Script -ErrorAction SilentlyContinue + } + + It "queues selected packages with the configured package manager preference" { + Invoke-WPFInstall + + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { + $ScriptBlock -is [scriptblock] -and + $ParameterList.Count -eq 2 -and + $ParameterList[0][0] -eq "PackagesToInstall" -and + @($ParameterList[0][1]).Count -eq 1 -and + @($ParameterList[0][1])[0].winget -eq "Git.Git" -and + $ParameterList[1][0] -eq "ManagerPreference" -and + $ParameterList[1][1] -eq [PackageManagers]::Winget + } + Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Component -eq "Install" -and + $Message -eq "Install selected package(s): Git (winget: Git.Git)" + } + } + + It "prompts and exits when no packages are selected" { + New-WinUtilInstallTestContext + + Invoke-WPFInstall + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "Please select the program(s) to install or upgrade." -and + $Title -eq "Winutil" -and + $Button -eq "OK" -and + $Icon -eq "Warning" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + } + + It "prompts and exits when another install process is running" { + New-WinUtilInstallTestContext -ProcessRunning $true -Packages @($script:package) + + Invoke-WPFInstall + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "[Invoke-WPFInstall] An Install process is currently running." -and + $Title -eq "Winutil" -and + $Button -eq "OK" -and + $Icon -eq "Warning" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + } +} + +Describe "Invoke-WPFInstall runspace body" { + BeforeEach { + $script:package = New-WinUtilPackage + New-WinUtilInstallTestContext -Packages @($script:package) + $script:capturedInstallScriptBlock = $null + + Mock Show-WinUtilMessage { "OK" } + Mock Invoke-WPFRunspace { + $script:capturedInstallScriptBlock = $ScriptBlock + [pscustomobject]@{ MockHandle = $true } + } + Mock Get-WinUtilSelectedPackages { + New-WinUtilPackageSplit -Winget @("Git.Git") -Choco @("vlc") + } + Mock Show-WPFInstallAppBusy { } + Mock Hide-WPFInstallAppBusy { } + Mock Install-WinUtilWinget { } + Mock Install-WinUtilChoco { } + Mock Install-WinUtilProgramWinget { } + Mock Install-WinUtilProgramChoco { } + Mock Invoke-WPFUIThread { } + Mock Write-WinUtilLog { } + Mock Write-Host { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name AppTitle -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue + } + + It "installs split winget and choco packages and cleans up on success" { + Invoke-WPFInstall + + & $script:capturedInstallScriptBlock -PackagesToInstall @($script:package) -ManagerPreference ([PackageManagers]::Winget) + + Should -Invoke -CommandName Get-WinUtilSelectedPackages -Times 1 -Exactly -ParameterFilter { + @($PackageList).Count -eq 1 -and $Preference -eq [PackageManagers]::Winget + } + Should -Invoke -CommandName Show-WPFInstallAppBusy -Times 1 -Exactly -ParameterFilter { + $text -eq "Installing apps..." + } + Should -Invoke -CommandName Install-WinUtilWinget -Times 1 -Exactly + Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly -ParameterFilter { + $Action -eq "Install" -and @($Programs)[0] -eq "Git.Git" + } + Should -Invoke -CommandName Install-WinUtilChoco -Times 1 -Exactly + Should -Invoke -CommandName Install-WinUtilProgramChoco -Times 1 -Exactly -ParameterFilter { + $Action -eq "Install" -and @($Programs)[0] -eq "vlc" + } + Should -Invoke -CommandName Hide-WPFInstallAppBusy -Times 1 -Exactly + Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + } + $script:sync.ProcessRunning | Should -BeFalse + } + + It "hides the busy overlay, sets taskbar error state, and clears ProcessRunning on failure" { + Mock Install-WinUtilProgramWinget { throw "winget failed" } + + Invoke-WPFInstall + + & $script:capturedInstallScriptBlock -PackagesToInstall @($script:package) -ManagerPreference ([PackageManagers]::Winget) + + Should -Invoke -CommandName Hide-WPFInstallAppBusy -Times 1 -Exactly + Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' + } + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and $Component -eq "Install" -and $Message -like "Install workflow failed:*" + } + $script:sync.ProcessRunning | Should -BeFalse + } +} + +Describe "Invoke-WPFUnInstall entrypoint" { + BeforeEach { + $script:package = New-WinUtilPackage + New-WinUtilInstallTestContext -Packages @($script:package) + $script:capturedUninstallScriptBlock = $null + $script:capturedUninstallParameterList = $null + + Mock Show-WinUtilMessage { "Yes" } + Mock Invoke-WPFRunspace { + $script:capturedUninstallScriptBlock = $ScriptBlock + $script:capturedUninstallParameterList = $ParameterList + [pscustomobject]@{ MockHandle = $true } + } + Mock Write-WinUtilLog { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name AppTitle -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedUninstallScriptBlock -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedUninstallParameterList -Scope Script -ErrorAction SilentlyContinue + } + + It "confirms and queues selected packages with the configured package manager preference" { + Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -like "*This will uninstall the following applications:*" -and + $Message -like "*Git*" -and + $Title -eq "Are you sure?" -and + "$Button" -eq "YesNo" -and + "$Icon" -eq "Information" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { + $ScriptBlock -is [scriptblock] -and + $ParameterList.Count -eq 2 -and + $ParameterList[0][0] -eq "PackagesToUninstall" -and + @($ParameterList[0][1]).Count -eq 1 -and + @($ParameterList[0][1])[0].winget -eq "Git.Git" -and + $ParameterList[1][0] -eq "ManagerPreference" -and + $ParameterList[1][1] -eq [PackageManagers]::Winget + } + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Component -eq "Uninstall" -and + $Message -eq "Uninstall selected package(s): Git (winget: Git.Git)" + } + } + + It "prompts and exits when no packages are selected" { + Invoke-WPFUnInstall -PackagesToUninstall @() + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "Please select the program(s) to uninstall" -and + $Title -eq "Winutil" -and + $Button -eq "OK" -and + $Icon -eq "Warning" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + } + + It "prompts and exits when another install process is running" { + $script:sync.ProcessRunning = $true + + Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Message -eq "[Invoke-WPFUnInstall] Install process is currently running" -and + $Title -eq "Winutil" -and + $Button -eq "OK" -and + $Icon -eq "Warning" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + } + + It "exits without queueing uninstall when confirmation is declined" { + Mock Show-WinUtilMessage { "No" } -ParameterFilter { $Title -eq "Are you sure?" } + + Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + + Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly + } +} + +Describe "Invoke-WPFUnInstall runspace body" { + BeforeEach { + $script:package = New-WinUtilPackage + New-WinUtilInstallTestContext -Packages @($script:package) + $script:capturedUninstallScriptBlock = $null + + Mock Show-WinUtilMessage { "Yes" } + Mock Invoke-WPFRunspace { + $script:capturedUninstallScriptBlock = $ScriptBlock + [pscustomobject]@{ MockHandle = $true } + } + Mock Get-WinUtilSelectedPackages { + New-WinUtilPackageSplit -Winget @("Git.Git") -Choco @("vlc") + } + Mock Show-WPFInstallAppBusy { } + Mock Hide-WPFInstallAppBusy { } + Mock Install-WinUtilProgramWinget { } + Mock Install-WinUtilProgramChoco { } + Mock Invoke-WPFUIThread { } + Mock Write-WinUtilLog { } + Mock Write-Host { } + Mock New-Item { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name AppTitle -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name capturedUninstallScriptBlock -Scope Script -ErrorAction SilentlyContinue + } + + It "uninstalls split winget and choco packages and cleans up on success" { + Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + + & $script:capturedUninstallScriptBlock -PackagesToUninstall @($script:package) -ManagerPreference ([PackageManagers]::Winget) + + Should -Invoke -CommandName Get-WinUtilSelectedPackages -Times 1 -Exactly -ParameterFilter { + @($PackageList).Count -eq 1 -and $Preference -eq [PackageManagers]::Winget + } + Should -Invoke -CommandName Show-WPFInstallAppBusy -Times 1 -Exactly -ParameterFilter { + $text -eq "Uninstalling apps..." + } + Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly -ParameterFilter { + $Action -eq "Uninstall" -and @($Programs)[0] -eq "Git.Git" + } + Should -Invoke -CommandName Install-WinUtilProgramChoco -Times 1 -Exactly -ParameterFilter { + $Action -eq "Uninstall" -and @($Programs)[0] -eq "vlc" + } + Should -Invoke -CommandName Hide-WPFInstallAppBusy -Times 1 -Exactly + Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*' + } + $script:sync.ProcessRunning | Should -BeFalse + } + + It "hides the busy overlay, sets taskbar error state, and clears ProcessRunning on failure" { + Mock Install-WinUtilProgramWinget { throw "winget failed" } + + Invoke-WPFUnInstall -PackagesToUninstall @($script:package) + + & $script:capturedUninstallScriptBlock -PackagesToUninstall @($script:package) -ManagerPreference ([PackageManagers]::Winget) + + Should -Invoke -CommandName Hide-WPFInstallAppBusy -Times 1 -Exactly + Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter { + $ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*' + } + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Level -eq "ERROR" -and $Component -eq "Uninstall" -and $Message -like "Uninstall workflow failed:*" + } + $script:sync.ProcessRunning | Should -BeFalse + } +} diff --git a/pester/logging.Tests.ps1 b/pester/logging.Tests.ps1 new file mode 100644 index 00000000..79c3d745 --- /dev/null +++ b/pester/logging.Tests.ps1 @@ -0,0 +1,107 @@ +#=========================================================================== +# Tests - WinUtil Logging +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + . (Join-Path $script:repoRoot "functions\private\Write-WinUtilLog.ps1") +} + +Describe "Write-WinUtilLog" { + BeforeEach { + $script:testRoot = Join-Path ([System.IO.Path]::GetTempPath()) "winutil-logging-$([guid]::NewGuid())" + New-Item -Path $script:testRoot -ItemType Directory -Force | Out-Null + Remove-Variable -Name WinUtilLogPath -Scope Script -ErrorAction SilentlyContinue + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name WinUtilLogPath -Scope Script -ErrorAction SilentlyContinue + Remove-Item -Path $script:testRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + It "writes to the active timestamped session log under logs" { + $logPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log" + $script:sync = [hashtable]::Synchronized(@{ + winutildir = $script:testRoot + logPath = $logPath + }) + + Write-WinUtilLog -Component "Test" -Message "same session log" + + Test-Path -Path $logPath | Should -BeTrue + Test-Path -Path (Join-Path $script:testRoot "winutil.log") | Should -BeFalse + Get-Content -Path $logPath -Raw | Should -Match "\[INFO\] \[Test\] same session log" + } + + It "uses the transcript stream when logPath is not set" { + $transcriptPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log" + $script:sync = [hashtable]::Synchronized(@{ + winutildir = $script:testRoot + transcriptPath = $transcriptPath + }) + Mock Add-Content { } + Mock Write-Host { } + + Write-WinUtilLog -Component "Test" -Message "transcript fallback" + + Should -Invoke -CommandName Add-Content -Times 0 -Exactly + Should -Invoke -CommandName Write-Host -Times 1 -Exactly -ParameterFilter { + $Object -match "\[INFO\] \[Test\] transcript fallback" + } + Test-Path -Path (Join-Path $script:testRoot "winutil.log") | Should -BeFalse + } + + It "creates one fallback log under logs when only winutildir is available" { + $script:sync = [hashtable]::Synchronized(@{ + winutildir = $script:testRoot + }) + + Write-WinUtilLog -Component "Test" -Message "first fallback entry" + Write-WinUtilLog -Component "Test" -Message "second fallback entry" + + $logFiles = @(Get-ChildItem -Path (Join-Path $script:testRoot "logs") -Filter "winutil_*.log") + $logFiles.Count | Should -Be 1 + Test-Path -Path (Join-Path $script:testRoot "winutil.log") | Should -BeFalse + + $content = Get-Content -Path $logFiles[0].FullName -Raw + $content | Should -Match "first fallback entry" + $content | Should -Match "second fallback entry" + } + + It "does not append directly when the active log file is the transcript" { + $logPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log" + $script:sync = [hashtable]::Synchronized(@{ + winutildir = $script:testRoot + logPath = $logPath + transcriptPath = $logPath + }) + + Mock Add-Content { throw [System.IO.IOException]::new("locked by transcript") } -ParameterFilter { + $Path -eq $logPath -and $ErrorAction -eq "Stop" + } + Mock Write-Host { } + Mock Write-Warning { } + + Write-WinUtilLog -Component "Test" -Message "transcript stream fallback" + + Should -Invoke -CommandName Add-Content -Times 0 -Exactly + Should -Invoke -CommandName Write-Host -Times 1 -Exactly -ParameterFilter { + $Object -match "\[INFO\] \[Test\] transcript stream fallback" + } + Should -Invoke -CommandName Write-Warning -Times 0 -Exactly + } + +} + +Describe "WinUtil startup logging path" { + It "uses one timestamped log file under the logs directory" { + $startScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\start.ps1") -Raw + + $startScript | Should -Match '\$sync\.logPath = "\$logdir\\winutil_\$dateTime\.log"' + $startScript | Should -Match '\$sync\.transcriptPath = \$sync\.logPath' + $startScript | Should -Match 'Start-Transcript -Path \$sync\.logPath' + $startScript | Should -Not -Match '\$sync\.logPath = "\$winutildir\\winutil\.log"' + } +} diff --git a/pester/package.Tests.ps1 b/pester/package.Tests.ps1 new file mode 100644 index 00000000..e5a0e468 --- /dev/null +++ b/pester/package.Tests.ps1 @@ -0,0 +1,181 @@ +#=========================================================================== +# Tests - Package Selection and Package Managers +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + if (-not ("PackageManagers" -as [type])) { + Add-Type @" +public enum PackageManagers +{ + Winget, + Choco +} +"@ + } + + . (Join-Path $script:repoRoot "functions\private\Get-WinUtilSelectedPackages.ps1") + . (Join-Path $script:repoRoot "functions\private\Test-WinUtilPackageManager.ps1") + . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramWinget.ps1") + . (Join-Path $script:repoRoot "functions\private\Install-WinUtilProgramChoco.ps1") + + function Invoke-WPFUIThread { } + function Write-WinUtilLog { } +} + +Describe "Get-WinUtilSelectedPackages" { + BeforeEach { + Mock Invoke-WPFUIThread { } + } + + It "uses winget IDs when winget is preferred" { + $packages = @( + [pscustomobject]@{ winget = "Git.Git"; choco = "git" } + [pscustomobject]@{ winget = "VideoLAN.VLC"; choco = "vlc" } + ) + + $result = Get-WinUtilSelectedPackages -PackageList $packages -Preference ([PackageManagers]::Winget) + + (@($result[[PackageManagers]::Winget]) -join "|") | Should -Be "Git.Git|VideoLAN.VLC" + @($result[[PackageManagers]::Choco]).Count | Should -Be 0 + } + + It "uses choco IDs and falls back to winget for na or missing choco IDs" { + $packages = @( + [pscustomobject]@{ winget = "Git.Git"; choco = "git" } + [pscustomobject]@{ winget = "VideoLAN.VLC"; choco = "na" } + [pscustomobject]@{ winget = "Mozilla.Firefox" } + ) + + $result = Get-WinUtilSelectedPackages -PackageList $packages -Preference ([PackageManagers]::Choco) + + (@($result[[PackageManagers]::Choco]) -join "|") | Should -Be "git" + (@($result[[PackageManagers]::Winget]) -join "|") | Should -Be "VideoLAN.VLC|Mozilla.Firefox" + } + + It "skips blank, na, and missing package IDs" { + $packages = @( + [pscustomobject]@{ winget = ""; choco = "" } + [pscustomobject]@{ winget = "na"; choco = "na" } + [pscustomobject]@{ choco = "only-choco" } + [pscustomobject]@{ winget = " " } + ) + + $result = Get-WinUtilSelectedPackages -PackageList $packages -Preference ([PackageManagers]::Winget) + + @($result[[PackageManagers]::Winget]).Count | Should -Be 0 + @($result[[PackageManagers]::Choco]).Count | Should -Be 0 + } + + It "deduplicates package IDs" { + $packages = @( + [pscustomobject]@{ winget = "Git.Git"; choco = "git" } + [pscustomobject]@{ winget = "Git.Git"; choco = "git" } + [pscustomobject]@{ winget = "VideoLAN.VLC"; choco = "vlc" } + ) + + $result = Get-WinUtilSelectedPackages -PackageList $packages -Preference ([PackageManagers]::Choco) + + (@($result[[PackageManagers]::Choco]) -join "|") | Should -Be "git|vlc" + @($result[[PackageManagers]::Winget]).Count | Should -Be 0 + } + + It "returns empty package lists for an empty selection" { + $result = Get-WinUtilSelectedPackages -PackageList @() -Preference ([PackageManagers]::Winget) + + @($result[[PackageManagers]::Winget]).Count | Should -Be 0 + @($result[[PackageManagers]::Choco]).Count | Should -Be 0 + } +} + +Describe "Test-WinUtilPackageManager" { + BeforeEach { + Mock Write-Host { } + } + + It "reports winget installed when the command exists" { + Mock Get-Command { + [pscustomobject]@{ Name = "winget" } + } -ParameterFilter { $Name -eq "winget" -and $ErrorAction -eq "SilentlyContinue" } + + Test-WinUtilPackageManager -winget | Should -Be "installed" + + Should -Invoke -CommandName Get-Command -Times 1 -Exactly -ParameterFilter { + $Name -eq "winget" -and $ErrorAction -eq "SilentlyContinue" + } + } + + It "reports choco not installed when the command is missing" { + Mock Get-Command { + $null + } -ParameterFilter { $Name -eq "choco" -and $ErrorAction -eq "SilentlyContinue" } + + Test-WinUtilPackageManager -choco | Should -Be "not-installed" + + Should -Invoke -CommandName Get-Command -Times 1 -Exactly -ParameterFilter { + $Name -eq "choco" -and $ErrorAction -eq "SilentlyContinue" + } + } +} + +Describe "Install-WinUtilProgramWinget" { + BeforeEach { + Mock Write-WinUtilLog { } + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + } + + It "starts winget with install arguments" { + Install-WinUtilProgramWinget -Action Install -Programs @("Git.Git") + + Should -Invoke -CommandName Start-Process -Times 1 -Exactly -ParameterFilter { + $FilePath -eq "winget" -and + (@($ArgumentList) -join "|") -eq "install|--id|Git.Git|--accept-package-agreements|--accept-source-agreements|--source|winget|--silent" -and + $NoNewWindow -eq $true -and + $Wait -eq $true -and + $PassThru -eq $true + } + } + + It "starts winget with uninstall arguments and msstore source when requested" { + Install-WinUtilProgramWinget -Action Uninstall -Programs @("msstore:9NBLGGH4NNS1") + + Should -Invoke -CommandName Start-Process -Times 1 -Exactly -ParameterFilter { + $FilePath -eq "winget" -and + (@($ArgumentList) -join "|") -eq "uninstall|--id|9NBLGGH4NNS1|--source|msstore|--silent" + } + } + + It "skips whitespace and na package IDs" { + Install-WinUtilProgramWinget -Action Install -Programs @(" ", "na") + + Should -Invoke -CommandName Start-Process -Times 0 -Exactly + } +} + +Describe "Install-WinUtilProgramChoco" { + BeforeEach { + Mock Write-WinUtilLog { } + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + } + + It "starts choco with install arguments" { + Install-WinUtilProgramChoco -Action Install -Programs @("git", "vlc") + + Should -Invoke -CommandName Start-Process -Times 1 -Exactly -ParameterFilter { + $FilePath -eq "choco" -and + $ArgumentList -eq "install git vlc -y" -and + $NoNewWindow -eq $true -and + $Wait -eq $true -and + $PassThru -eq $true + } + } + + It "starts choco with uninstall arguments" { + Install-WinUtilProgramChoco -Action Uninstall -Programs @("git") + + Should -Invoke -CommandName Start-Process -Times 1 -Exactly -ParameterFilter { + $FilePath -eq "choco" -and $ArgumentList -eq "uninstall git -y" + } + } +} diff --git a/pester/preferences-theme.Tests.ps1 b/pester/preferences-theme.Tests.ps1 new file mode 100644 index 00000000..c65eba3e --- /dev/null +++ b/pester/preferences-theme.Tests.ps1 @@ -0,0 +1,347 @@ +#=========================================================================== +# Tests - Preferences and Themes +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + if (-not ("PackageManagers" -as [type])) { + Add-Type @" +public enum PackageManagers +{ + Winget, + Choco +} +"@ + } + + if (-not ("Windows.Media.SolidColorBrush" -as [type])) { + Add-Type @" +namespace Windows.Media +{ + public class SolidColorBrush + { + public object Value { get; private set; } + + public SolidColorBrush(object value) + { + Value = value; + } + + public override string ToString() + { + return Value == null ? "" : Value.ToString(); + } + } + + public struct Color + { + public byte R; + public byte G; + public byte B; + + public static Color FromRgb(byte r, byte g, byte b) + { + return new Color { R = r, G = g, B = b }; + } + + public override string ToString() + { + return string.Format("#{0:X2}{1:X2}{2:X2}", R, G, B); + } + } + + public class FontFamily + { + public string Value { get; private set; } + + public FontFamily(string value) + { + Value = value; + } + + public override string ToString() + { + return Value; + } + } +} + +namespace System.Windows +{ + public class CornerRadius + { + public double Value { get; private set; } + + public CornerRadius(double value) + { + Value = value; + } + + public override string ToString() + { + return Value.ToString(); + } + } + + public class GridLength + { + public double Value { get; private set; } + + public GridLength(double value) + { + Value = value; + } + + public override string ToString() + { + return Value.ToString(); + } + } + + public class Thickness + { + public double Left { get; private set; } + public double Top { get; private set; } + public double Right { get; private set; } + public double Bottom { get; private set; } + + public Thickness(double uniformLength) + { + Left = uniformLength; + Top = uniformLength; + Right = uniformLength; + Bottom = uniformLength; + } + + public Thickness(double horizontal, double vertical) + { + Left = horizontal; + Top = vertical; + Right = horizontal; + Bottom = vertical; + } + + public Thickness(double left, double top, double right, double bottom) + { + Left = left; + Top = top; + Right = right; + Bottom = bottom; + } + + public override string ToString() + { + return string.Format("{0},{1},{2},{3}", Left, Top, Right, Bottom); + } + } +} +"@ + } + + . (Join-Path $script:repoRoot "functions\private\Set-Preferences.ps1") + . (Join-Path $script:repoRoot "functions\private\Invoke-WinutilThemeChange.ps1") + + function Get-WinUtilToggleStatus { + param($ToggleName) + return $false + } + + function script:New-WinUtilPreferencesTestRoot { + $testRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilPreferences_$([guid]::NewGuid())" + New-Item -Path $testRoot -ItemType Directory -Force | Out-Null + $testRoot + } + + function script:New-WinUtilFakeThemeForm { + $themeButton = [pscustomobject]@{ + Content = $null + } + + $form = [pscustomobject]@{ + Resources = @{} + ThemeButton = $themeButton + } + $form | Add-Member -MemberType ScriptMethod -Name FindName -Value { + param($name) + + if ($name -eq "ThemeButton") { + return $this.ThemeButton + } + + return $null + } + + $form + } + + function script:New-WinUtilPreferenceSync { + param( + [hashtable]$Preferences = @{} + ) + + $script:sync = [Hashtable]::Synchronized(@{ + preferences = $Preferences + configs = @{ + themes = Get-Content -Path (Join-Path $script:repoRoot "config\themes.json") -Raw | ConvertFrom-Json + } + Form = New-WinUtilFakeThemeForm + }) + $global:sync = $script:sync + } + + function script:Remove-WinUtilPreferenceGlobals { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + Remove-Variable -Name winutildir -Scope Global -ErrorAction SilentlyContinue + } +} + +Describe "Set-Preferences" { + AfterEach { + if ($script:testRoot -and (Test-Path $script:testRoot)) { + Remove-Item -Path $script:testRoot -Recurse -Force -ErrorAction SilentlyContinue + } + $script:testRoot = $null + Remove-WinUtilPreferenceGlobals + } + + It "loads default preferences when no preferences file exists" { + $script:testRoot = New-WinUtilPreferencesTestRoot + $global:winutildir = $script:testRoot + New-WinUtilPreferenceSync + + Set-Preferences + + $script:sync.preferences.theme | Should -Be "Auto" + $script:sync.preferences.packagemanager | Should -Be ([PackageManagers]::Winget) + } + + It "loads saved preferences and converts the package manager to an enum" { + $script:testRoot = New-WinUtilPreferencesTestRoot + $global:winutildir = $script:testRoot + New-WinUtilPreferenceSync + Set-Content -Path (Join-Path $script:testRoot "preferences.ini") -Value @( + "theme = Dark" + "packagemanager = Choco" + ) + + Set-Preferences + + $script:sync.preferences.theme | Should -Be "Dark" + $script:sync.preferences.packagemanager | Should -Be ([PackageManagers]::Choco) + } + + It "saves current preferences to preferences.ini" { + $script:testRoot = New-WinUtilPreferencesTestRoot + $global:winutildir = $script:testRoot + New-WinUtilPreferenceSync -Preferences @{ + theme = "Light" + packagemanager = [PackageManagers]::Winget + } + + Set-Preferences -save + + $preferencesText = Get-Content -Path (Join-Path $script:testRoot "preferences.ini") -Raw + $preferencesText | Should -Match "theme=Light" + $preferencesText | Should -Match "packagemanager=Winget" + } + + It "absorbs legacy preference files and removes old markers" { + $script:testRoot = New-WinUtilPreferencesTestRoot + $global:winutildir = $script:testRoot + New-WinUtilPreferenceSync + Set-Content -Path (Join-Path $script:testRoot "LightTheme.ini") -Value "" + Set-Content -Path (Join-Path $script:testRoot "preferChocolatey.ini") -Value "" + + Set-Preferences + + $script:sync.preferences.theme | Should -Be "Light" + $script:sync.preferences.packagemanager | Should -Be ([PackageManagers]::Choco) + Test-Path (Join-Path $script:testRoot "LightTheme.ini") | Should -BeFalse + Test-Path (Join-Path $script:testRoot "preferChocolatey.ini") | Should -BeFalse + } + + It "absorbs old package manager preferences stored without a key" { + $script:testRoot = New-WinUtilPreferencesTestRoot + $global:winutildir = $script:testRoot + New-WinUtilPreferenceSync + Set-Content -Path (Join-Path $script:testRoot "preferences.ini") -Value "Choco" + + Set-Preferences + + $script:sync.preferences.theme | Should -Be "Auto" + $script:sync.preferences.packagemanager | Should -Be ([PackageManagers]::Choco) + } +} + +Describe "Invoke-WinutilThemeChange" { + AfterEach { + if ($script:testRoot -and (Test-Path $script:testRoot)) { + Remove-Item -Path $script:testRoot -Recurse -Force -ErrorAction SilentlyContinue + } + $script:testRoot = $null + Remove-WinUtilPreferenceGlobals + } + + It "applies shared and selected theme resources, saves the preference, and updates the theme button" { + $script:testRoot = New-WinUtilPreferencesTestRoot + $global:winutildir = $script:testRoot + New-WinUtilPreferenceSync + + Invoke-WinutilThemeChange -theme "Dark" + + $script:sync.preferences.theme | Should -Be "Dark" + $script:sync.Form.Resources.ContainsKey("FontFamily") | Should -BeTrue + $script:sync.Form.Resources.ContainsKey("ButtonCornerRadius") | Should -BeTrue + $script:sync.Form.Resources.ContainsKey("MainBackgroundColor") | Should -BeTrue + $script:sync.Form.Resources.ContainsKey("CBorderColor") | Should -BeTrue + $script:sync.Form.ThemeButton.Content | Should -Be ([string][char]0xE708) + + $preferencesText = Get-Content -Path (Join-Path $script:testRoot "preferences.ini") -Raw + $preferencesText | Should -Match "theme=Dark" + } + + It "uses the system dark-mode toggle when Auto theme is selected" { + $script:testRoot = New-WinUtilPreferencesTestRoot + $global:winutildir = $script:testRoot + New-WinUtilPreferenceSync + Mock Get-WinUtilToggleStatus { return $true } + + Invoke-WinutilThemeChange -theme "Auto" + + Should -Invoke -CommandName Get-WinUtilToggleStatus -Times 1 -Exactly -ParameterFilter { + $ToggleName -eq "WPFToggleDarkMode" + } + $script:sync.preferences.theme | Should -Be "Auto" + $script:sync.Form.Resources.ContainsKey("MainBackgroundColor") | Should -BeTrue + $script:sync.Form.ThemeButton.Content | Should -Be ([string][char]0xF08C) + } +} + +Describe "Theme configuration" { + It "contains resources with value shapes consumed by theme application" { + $themes = Get-Content -Path (Join-Path $script:repoRoot "config\themes.json") -Raw | ConvertFrom-Json + $invalidEntries = [System.Collections.Generic.List[string]]::new() + + foreach ($themeName in @("shared", "Light", "Dark")) { + foreach ($property in $themes.$themeName.PSObject.Properties) { + if ($property.Name -like "*Color" -and [string]$property.Value -notmatch '^(#[0-9A-Fa-f]{6}|Transparent)$') { + $invalidEntries.Add("$themeName.$($property.Name) should be a hex color or Transparent") + } + elseif ($property.Name -like "*Radius" -and [string]$property.Value -notmatch '^\d+(\.\d+)?$') { + $invalidEntries.Add("$themeName.$($property.Name) should be numeric") + } + elseif (($property.Name -like "*Thickness" -or $property.Name -like "*Margin") -and [string]$property.Value -notmatch '^\d+(\.\d+)?(,\d+(\.\d+)?){0,3}$') { + $invalidEntries.Add("$themeName.$($property.Name) should be one, two, or four numeric values") + } + elseif ($property.Name -like "*RowHeight*" -and [string]$property.Value -notmatch '^\d+(\.\d+)?$') { + $invalidEntries.Add("$themeName.$($property.Name) should be numeric") + } + } + } + + if ($invalidEntries.Count -gt 0) { + throw ($invalidEntries -join "`n") + } + } +} diff --git a/pester/runspace.Tests.ps1 b/pester/runspace.Tests.ps1 new file mode 100644 index 00000000..9cb42e85 --- /dev/null +++ b/pester/runspace.Tests.ps1 @@ -0,0 +1,168 @@ +#=========================================================================== +# Tests - Runspace Behavior +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFFeatureInstall.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxRemoval.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFundoall.ps1") + + function script:New-WinUtilRunspaceTestContext { + param([hashtable]$InitialSync = @{}) + + $script:sync = [Hashtable]::Synchronized($InitialSync) + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + $syncVariable = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList "sync", $script:sync, $null + $initialSessionState.Variables.Add($syncVariable) + $script:sync.runspace = [runspacefactory]::CreateRunspacePool(1, 2, $initialSessionState, $Host) + $script:sync.runspace.Open() + } + + function script:Clear-WinUtilRunspaceTestContext { + if ($script:powershell) { + $script:powershell.Dispose() + } + + if ($script:sync -and $script:sync.runspace) { + $script:sync.runspace.Close() + $script:sync.runspace.Dispose() + } + + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name powershell -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name handle -Scope Script -ErrorAction SilentlyContinue + } + + function script:Assert-WinUtilAsyncHandle { + param($Handle) + + ($Handle -is [System.IAsyncResult]) | Should -BeTrue + ($Handle -is [array]) | Should -BeFalse + $Handle.AsyncWaitHandle.WaitOne(5000) | Should -BeTrue + } +} + +Describe "Invoke-WPFRunspace behavior" { + BeforeEach { + New-WinUtilRunspaceTestContext -InitialSync @{ Marker = "shared" } + } + + AfterEach { + Clear-WinUtilRunspaceTestContext + } + + It "returns a single async handle with no argument list" { + $handle = Invoke-WPFRunspace -ScriptBlock { + Start-Sleep -Milliseconds 100 + "no-args|$($sync.Marker)" + } + + Assert-WinUtilAsyncHandle -Handle $handle + @($script:powershell.EndInvoke($handle))[0] | Should -Be "no-args|shared" + } + + It "passes one named parameter" { + $handle = Invoke-WPFRunspace -ParameterList @(,("Name", "value")) -ScriptBlock { + param([string]$Name) + + Start-Sleep -Milliseconds 100 + "Name=$Name" + } + + Assert-WinUtilAsyncHandle -Handle $handle + @($script:powershell.EndInvoke($handle))[0] | Should -Be "Name=value" + } + + It "passes multiple named parameters" { + $handle = Invoke-WPFRunspace -ParameterList @( + ("First", "alpha"), + ("Second", "beta") + ) -ScriptBlock { + param( + [string]$First, + [string]$Second + ) + + Start-Sleep -Milliseconds 100 + "$First|$Second|$($sync.Marker)" + } + + Assert-WinUtilAsyncHandle -Handle $handle + @($script:powershell.EndInvoke($handle))[0] | Should -Be "alpha|beta|shared" + } + + It "surfaces scriptblock failures through the owning PowerShell instance" { + $handle = Invoke-WPFRunspace -ScriptBlock { + Start-Sleep -Milliseconds 100 + throw "runspace failure" + } + + Assert-WinUtilAsyncHandle -Handle $handle + { $script:powershell.EndInvoke($handle) } | Should -Throw -ExpectedMessage "*runspace failure*" + } +} + +Describe "Public runspace callers" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ProcessRunning = $false + selectedFeatures = [System.Collections.Generic.List[string]]::new() + selectedTweaks = [System.Collections.Generic.List[string]]::new() + selectedAppx = [System.Collections.Generic.List[string]]::new() + configs = @{ + appxHashtable = @{} + } + }) + + Mock Invoke-WPFRunspace { [pscustomobject]@{ MockHandle = $true } } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + It "queues selected feature installation without executing the runspace body" { + $script:sync.selectedFeatures.Add("WPFFeaturesSandbox") + + Invoke-WPFFeatureInstall + + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { + $ScriptBlock -is [scriptblock] -and $null -eq $ArgumentList -and $null -eq $ParameterList + } + } + + It "passes selected tweaks as the runspace argument list for undo all" { + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:sync.selectedTweaks.Add("WPFTweaksServices") + + Invoke-WPFundoall + + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { + $ScriptBlock -is [scriptblock] -and + $ArgumentList.Count -eq 2 -and + $ArgumentList[0] -eq "WPFTweaksTelemetry" -and + $ArgumentList[1] -eq "WPFTweaksServices" + } + } + + It "passes selected AppX items and app metadata to the removal runspace" { + $script:sync.selectedAppx.Add("WPFAppxExample") + $script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{ + Content = "Example" + PackageId = "Example.Package" + } + + Invoke-WPFAppxRemoval + + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { + $ScriptBlock -is [scriptblock] -and + $ParameterList.Count -eq 2 -and + $ParameterList[0][0] -eq "selected" -and + $ParameterList[0][1][0] -eq "WPFAppxExample" -and + $ParameterList[1][0] -eq "apps" -and + $ParameterList[1][1].ContainsKey("WPFAppxExample") + } + } +} diff --git a/pester/sanity.Tests.ps1 b/pester/sanity.Tests.ps1 new file mode 100644 index 00000000..91c62aa8 --- /dev/null +++ b/pester/sanity.Tests.ps1 @@ -0,0 +1,259 @@ +#=========================================================================== +# Tests - General Sanity +#=========================================================================== + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + function script:Test-WinUtilParser { + param([string]$Path) + + $tokens = $null + $syntaxErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$syntaxErrors) | Out-Null + + if ($syntaxErrors.Count -ne 0) { + throw ($syntaxErrors | Out-String) + } + } + + function script:Invoke-WindowsPowerShellParser { + param([string[]]$Path) + + $windowsPowerShell = Get-Command powershell.exe -ErrorAction SilentlyContinue + if (-not $windowsPowerShell) { + Set-ItResult -Skipped -Because "powershell.exe is not available on this platform." + return + } + + $previousPaths = $env:WINUTIL_TEST_PARSE_PATHS + try { + $env:WINUTIL_TEST_PARSE_PATHS = @($Path) -join [Environment]::NewLine + $parseScript = @' +$ErrorActionPreference = 'Stop' +$paths = $env:WINUTIL_TEST_PARSE_PATHS -split "`r?`n" | Where-Object { $_ } +$failed = @() + +foreach ($path in $paths) { + $tokens = $null + $syntaxErrors = $null + [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$syntaxErrors) | Out-Null + + if ($syntaxErrors.Count -ne 0) { + $messages = $syntaxErrors | ForEach-Object { $_.Message } + $failed += "[$path] $($messages -join '; ')" + } +} + +if ($failed.Count -gt 0) { + $failed -join [Environment]::NewLine + exit 1 +} +'@ + + $output = & $windowsPowerShell.Source -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command $parseScript 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Windows PowerShell parser failed:`n$($output | Out-String)" + } + } finally { + if ($null -eq $previousPaths) { + Remove-Item Env:\WINUTIL_TEST_PARSE_PATHS -ErrorAction SilentlyContinue + } else { + $env:WINUTIL_TEST_PARSE_PATHS = $previousPaths + } + } + } +} + +Describe "PowerShell source sanity" { + $scriptCases = @( + "Compile.ps1", + "windev.ps1", + "scripts\start.ps1", + "scripts\main.ps1" + ) | ForEach-Object { + @{ + Name = $_ + Path = Join-Path $repoRoot $_ + } + } + + foreach ($scriptCase in $scriptCases) { + It "parses $($scriptCase.Name) with the current PowerShell parser" -TestCases $scriptCase { + param([string]$Path) + + Test-WinUtilParser -Path $Path + } + } + + It "parses WinUtil source files with Windows PowerShell when available" { + $sourcePaths = @( + Join-Path $script:repoRoot "Compile.ps1" + Join-Path $script:repoRoot "windev.ps1" + Join-Path $script:repoRoot "scripts\start.ps1" + Join-Path $script:repoRoot "scripts\main.ps1" + Get-ChildItem -Path (Join-Path $script:repoRoot "functions") -Filter *.ps1 -Recurse | Select-Object -ExpandProperty FullName + ) + + Invoke-WindowsPowerShellParser -Path $sourcePaths + } +} + +Describe "Compiled WinUtil sanity" { + BeforeAll { + $script:compiledPath = Join-Path $script:repoRoot "winutil.ps1" + + Push-Location $script:repoRoot + try { + & (Join-Path $script:repoRoot "Compile.ps1") + } finally { + Pop-Location + } + } + + It "generates winutil.ps1" { + Test-Path -Path $script:compiledPath | Should -BeTrue + } + + It "parses compiled winutil.ps1 with the current PowerShell parser" { + Test-WinUtilParser -Path $script:compiledPath + } + + It "parses compiled winutil.ps1 with Windows PowerShell when available" { + Invoke-WindowsPowerShellParser -Path $script:compiledPath + } + + It "contains embedded configs, XAML, autounattend XML, and runspace bootstrap" { + $content = Get-Content -Path $script:compiledPath -Raw + $requiredSnippets = @( + ('$sync.configs.applications = @' + "'"), + ('$inputXML = @' + "'"), + ('$WinUtilAutounattendXml = @' + "'"), + "SessionStateVariableEntry -ArgumentList 'sync'", + "SessionStateFunctionEntry", + "[runspacefactory]::CreateRunspacePool", + "function Invoke-WPFRunspace" + ) + + foreach ($snippet in $requiredSnippets) { + if (-not $content.Contains($snippet)) { + throw "Compiled script is missing expected content: $snippet" + } + } + } + + It "transforms applications config keys with WPFInstall prefixes" { + $content = Get-Content -Path $script:compiledPath -Raw + $configMatch = [regex]::Match( + $content, + "(?s)\`$sync\.configs\.applications = @'\r?\n(?.*?)\r?\n'@ \| ConvertFrom-Json" + ) + + if (-not $configMatch.Success) { + throw "Compiled script is missing embedded applications config." + } + + $sourceApps = Get-Content -Path (Join-Path $script:repoRoot "config\applications.json") -Raw | ConvertFrom-Json + $compiledApps = $configMatch.Groups["json"].Value | ConvertFrom-Json + + foreach ($sourceApp in $sourceApps.PSObject.Properties) { + $compiledKey = "WPFInstall$($sourceApp.Name)" + if ($compiledApps.PSObject.Properties.Name -notcontains $compiledKey) { + throw "Compiled applications config is missing transformed key: $compiledKey" + } + if ($compiledApps.PSObject.Properties.Name -contains $sourceApp.Name) { + throw "Compiled applications config contains untransformed source key: $($sourceApp.Name)" + } + } + } + + It "preserves compile source ordering" { + $content = Get-Content -Path $script:compiledPath -Raw + $orderedSnippets = @( + '$sync.version =', + 'function Add-SelectedAppsMenuItem', + ('$sync.configs.applications = @' + "'"), + ('$inputXML = @' + "'"), + ('$WinUtilAutounattendXml = @' + "'"), + '$sync.SearchBarClearButton.Add_Click({' + ) + + $lastIndex = -1 + foreach ($snippet in $orderedSnippets) { + $index = $content.IndexOf($snippet) + if ($index -lt 0) { + throw "Compiled script is missing expected ordered content: $snippet" + } + if ($index -le $lastIndex) { + throw "Compiled script content is out of order near: $snippet" + } + + $lastIndex = $index + } + } + + It "replaces the generated build date placeholder" { + $content = Get-Content -Path $script:compiledPath -Raw + $expectedBuildDate = Get-Date -Format "yy.MM.dd" + + $content | Should -Not -Match ([regex]::Escape("#{replaceme}")) + $content | Should -Match ([regex]::Escape('$sync.version = "' + $expectedBuildDate + '"')) + } +} + +Describe "Runspace sanity" { + BeforeAll { + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") + } + + It "returns a single async handle and runs a scriptblock with arguments in the shared runspace pool" { + $script:sync = [Hashtable]::Synchronized(@{ SmokeValue = "shared" }) + $initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + $syncVariable = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList "sync", $script:sync, $null + $initialSessionState.Variables.Add($syncVariable) + $script:sync.runspace = [runspacefactory]::CreateRunspacePool(1, 2, $initialSessionState, $Host) + $script:sync.runspace.Open() + + $ended = $false + try { + $handle = Invoke-WPFRunspace -ArgumentList "argument" -ParameterList @(,("NamedValue", "parameter")) -ScriptBlock { + param($ArgumentValue, [string]$NamedValue) + + Start-Sleep -Milliseconds 200 + "$ArgumentValue|$NamedValue|$($sync.SmokeValue)" + } + + ($handle -is [System.IAsyncResult]) | Should -BeTrue + ($handle -is [array]) | Should -BeFalse + $handle.AsyncWaitHandle.WaitOne(5000) | Should -BeTrue + + $result = $script:powershell.EndInvoke($handle) + $ended = $true + + @($result)[0] | Should -Be "argument|parameter|shared" + } finally { + if (-not $ended -and $handle -and $handle.IsCompleted -and $script:powershell) { + try { + $script:powershell.EndInvoke($handle) | Out-Null + } catch { + # The assertion failure is more useful than cleanup errors here. + } + } + + if ($script:powershell) { + $script:powershell.Dispose() + } + + if ($script:sync -and $script:sync.runspace) { + $script:sync.runspace.Close() + $script:sync.runspace.Dispose() + } + + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name powershell -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name handle -Scope Script -ErrorAction SilentlyContinue + } + } +} diff --git a/pester/search-filter.Tests.ps1 b/pester/search-filter.Tests.ps1 new file mode 100644 index 00000000..8231f5d1 --- /dev/null +++ b/pester/search-filter.Tests.ps1 @@ -0,0 +1,446 @@ +#=========================================================================== +# Tests - Search and Filter Helpers +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + if (-not ("Windows.Visibility" -as [type])) { + Add-Type @" +namespace Windows +{ + public enum Visibility + { + Visible, + Collapsed + } +} +"@ + } + + if (-not ("System.Windows.Controls.CheckBox" -as [type])) { + Add-Type @" +namespace System.Windows.Controls +{ + public class CheckBox + { + public bool? IsChecked { get; set; } + } + + public class Label + { + public object Content { get; set; } + } + + public class WrapPanel + { + public object Visibility { get; set; } + } + + public class StackPanel + { + public System.Collections.ArrayList Children { get; private set; } + + public StackPanel() + { + Children = new System.Collections.ArrayList(); + } + } +} +"@ + } + + if (-not ("Windows.Controls.Border" -as [type])) { + Add-Type @" +namespace Windows.Controls +{ + public class Border + { + public object Child { get; set; } + public object Visibility { get; set; } + } + + public class DockPanel + { + public System.Collections.ArrayList Children { get; private set; } + public object Visibility { get; set; } + + public DockPanel() + { + Children = new System.Collections.ArrayList(); + } + } + + public class StackPanel + { + public System.Collections.ArrayList Children { get; private set; } + public object Visibility { get; set; } + + public StackPanel() + { + Children = new System.Collections.ArrayList(); + } + } + + public class ItemsControl + { + public System.Collections.ArrayList Items { get; private set; } + public object Visibility { get; set; } + + public ItemsControl() + { + Items = new System.Collections.ArrayList(); + } + } + + public class Label + { + public object Content { get; set; } + public object ToolTip { get; set; } + public object Visibility { get; set; } + } + + public class CheckBox + { + public object Content { get; set; } + public object ToolTip { get; set; } + public object Visibility { get; set; } + } +} +"@ + } + + . (Join-Path $script:repoRoot "functions\private\Find-AppsByNameOrDescription.ps1") + . (Join-Path $script:repoRoot "functions\private\Find-TweaksByNameOrDescription.ps1") + + function script:New-WinUtilSearchCollection { + return ,[System.Collections.ArrayList]::new() + } + + function script:New-WinUtilAppSearchItem { + param([string]$Tag) + + [pscustomobject]@{ + Tag = $Tag + Visibility = [Windows.Visibility]::Visible + } + } + + function script:New-WinUtilAppCategory { + param( + [string]$Label, + [object[]]$Items + ) + + $labelControl = [pscustomobject]@{ + Content = $Label + Visibility = [Windows.Visibility]::Visible + } + $wrapPanel = [pscustomobject]@{ + Children = New-WinUtilSearchCollection + Visibility = [Windows.Visibility]::Visible + } + + foreach ($item in $Items) { + $null = $wrapPanel.Children.Add($item) + } + + $children = New-WinUtilSearchCollection + $null = $children.Add($labelControl) + $null = $children.Add($wrapPanel) + + [pscustomobject]@{ + Children = $children + Visibility = [Windows.Visibility]::Visible + } + } + + function script:New-WinUtilAppSearchContext { + param([object[]]$Categories) + + $items = New-WinUtilSearchCollection + foreach ($category in $Categories) { + $null = $items.Add($category) + } + + $script:sync = [Hashtable]::Synchronized(@{ + ItemsControl = [pscustomobject]@{ + Items = $items + } + configs = @{ + applicationsHashtable = @{ + WPFInstallBrowser = [pscustomobject]@{ + Content = "Firefox" + Description = "Fast private browser" + } + WPFInstallMedia = [pscustomobject]@{ + Content = "VLC" + Description = "Media player" + } + WPFInstallLiteral = [pscustomobject]@{ + Content = "Tool [abc]" + Description = "Literal wildcard sample" + } + WPFInstallEditor = [pscustomobject]@{ + Content = "Code Editor" + Description = "Text editing" + } + } + } + }) + $global:sync = $script:sync + } + + function script:New-WinUtilFakeSearchForm { + param( + $TweaksPanel, + $AppxPanel + ) + + $form = [pscustomobject]@{ + tweakspanel = $TweaksPanel + appxpanel = $AppxPanel + } + $form | Add-Member -MemberType ScriptMethod -Name FindName -Value { + param($name) + + return $this.$name + } + + $form + } + + function script:New-WinUtilTweakLabelItem { + param( + [string]$Content, + [string]$ToolTip = "" + ) + + $item = [Windows.Controls.DockPanel]::new() + $checkbox = [Windows.Controls.CheckBox]::new() + $label = [Windows.Controls.Label]::new() + $label.Content = $Content + $label.ToolTip = $ToolTip + $null = $item.Children.Add($checkbox) + $null = $item.Children.Add($label) + $item.Visibility = [Windows.Visibility]::Visible + $item + } + + function script:New-WinUtilTweakCheckboxItem { + param( + [string]$Content, + [string]$ToolTip = "" + ) + + $item = [Windows.Controls.StackPanel]::new() + $checkbox = [Windows.Controls.CheckBox]::new() + $checkbox.Content = $Content + $checkbox.ToolTip = $ToolTip + $null = $item.Children.Add($checkbox) + $item.Visibility = [Windows.Visibility]::Visible + $item + } + + function script:New-WinUtilTweakCategory { + param( + [string]$Label, + [object[]]$Items + ) + + $categoryLabel = [Windows.Controls.Label]::new() + $categoryLabel.Content = $Label + $categoryLabel.Visibility = [Windows.Visibility]::Visible + + $itemsControl = [Windows.Controls.ItemsControl]::new() + $null = $itemsControl.Items.Add($categoryLabel) + foreach ($item in $Items) { + $null = $itemsControl.Items.Add($item) + } + + $dockPanel = [Windows.Controls.DockPanel]::new() + $null = $dockPanel.Children.Add($itemsControl) + + $border = [Windows.Controls.Border]::new() + $border.Child = $dockPanel + $border.Visibility = [Windows.Visibility]::Visible + + [pscustomobject]@{ + Border = $border + Label = $categoryLabel + ItemsControl = $itemsControl + } + } + + function script:New-WinUtilTweakPanel { + param([object[]]$Categories) + + $panel = [pscustomobject]@{ + Children = New-WinUtilSearchCollection + } + + foreach ($category in $Categories) { + $null = $panel.Children.Add($category.Border) + } + + $panel + } + + function script:New-WinUtilTweakSearchContext { + param( + $TweaksPanel, + $AppxPanel = $null, + [string]$CurrentTab = "Tweaks" + ) + + $script:sync = [Hashtable]::Synchronized(@{ + currentTab = $CurrentTab + Form = New-WinUtilFakeSearchForm -TweaksPanel $TweaksPanel -AppxPanel $AppxPanel + }) + $global:sync = $script:sync + } + + function script:Remove-WinUtilSearchGlobals { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } +} + +Describe "Find-AppsByNameOrDescription" { + AfterEach { + Remove-WinUtilSearchGlobals + } + + It "restores app visibility and respects collapsed category state for empty search" { + $browserItem = New-WinUtilAppSearchItem -Tag "WPFInstallBrowser" + $mediaItem = New-WinUtilAppSearchItem -Tag "WPFInstallMedia" + $browserItem.Visibility = [Windows.Visibility]::Collapsed + $mediaItem.Visibility = [Windows.Visibility]::Collapsed + + $collapsedCategory = New-WinUtilAppCategory -Label "+ Browsers" -Items @($browserItem) + $expandedCategory = New-WinUtilAppCategory -Label "- Media" -Items @($mediaItem) + $collapsedCategory.Children[1].Visibility = [Windows.Visibility]::Collapsed + $expandedCategory.Children[1].Visibility = [Windows.Visibility]::Collapsed + New-WinUtilAppSearchContext -Categories @($collapsedCategory, $expandedCategory) + + Find-AppsByNameOrDescription -SearchString "" + + $collapsedCategory.Visibility | Should -Be ([Windows.Visibility]::Visible) + $collapsedCategory.Children[0].Visibility | Should -Be ([Windows.Visibility]::Visible) + $collapsedCategory.Children[1].Visibility | Should -Be ([Windows.Visibility]::Collapsed) + $browserItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + $expandedCategory.Children[1].Visibility | Should -Be ([Windows.Visibility]::Visible) + $mediaItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + } + + It "shows matching apps by description and hides categories without matches" { + $browserItem = New-WinUtilAppSearchItem -Tag "WPFInstallBrowser" + $mediaItem = New-WinUtilAppSearchItem -Tag "WPFInstallMedia" + $editorItem = New-WinUtilAppSearchItem -Tag "WPFInstallEditor" + $browserCategory = New-WinUtilAppCategory -Label "+ Browsers" -Items @($browserItem, $mediaItem) + $editorCategory = New-WinUtilAppCategory -Label "- Editors" -Items @($editorItem) + New-WinUtilAppSearchContext -Categories @($browserCategory, $editorCategory) + + Find-AppsByNameOrDescription -SearchString "private" + + $browserCategory.Visibility | Should -Be ([Windows.Visibility]::Visible) + $browserCategory.Children[0].Content | Should -Be "- Browsers" + $browserCategory.Children[1].Visibility | Should -Be ([Windows.Visibility]::Visible) + $browserItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + $mediaItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + $editorCategory.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + $editorItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + } + + It "treats wildcard characters as literal app search text" { + $literalItem = New-WinUtilAppSearchItem -Tag "WPFInstallLiteral" + $mediaItem = New-WinUtilAppSearchItem -Tag "WPFInstallMedia" + $category = New-WinUtilAppCategory -Label "- Tools" -Items @($literalItem, $mediaItem) + New-WinUtilAppSearchContext -Categories @($category) + + Find-AppsByNameOrDescription -SearchString "[abc]" + + $literalItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + $mediaItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + $category.Visibility | Should -Be ([Windows.Visibility]::Visible) + } +} + +Describe "Find-TweaksByNameOrDescription" { + AfterEach { + Remove-WinUtilSearchGlobals + } + + It "restores category labels and tweak item visibility for empty search" { + $labelItem = New-WinUtilTweakLabelItem -Content "Disable Telemetry" -ToolTip "Stop tracking" + $stackItem = New-WinUtilTweakCheckboxItem -Content "Show Extensions" -ToolTip "File extension display" + $category = New-WinUtilTweakCategory -Label "+ Privacy" -Items @($labelItem, $stackItem) + $labelItem.Visibility = [Windows.Visibility]::Collapsed + $stackItem.Visibility = [Windows.Visibility]::Collapsed + $category.Label.Visibility = [Windows.Visibility]::Collapsed + $category.Border.Visibility = [Windows.Visibility]::Collapsed + $panel = New-WinUtilTweakPanel -Categories @($category) + New-WinUtilTweakSearchContext -TweaksPanel $panel + + Find-TweaksByNameOrDescription -SearchString "" + + $category.Border.Visibility | Should -Be ([Windows.Visibility]::Visible) + $category.Label.Visibility | Should -Be ([Windows.Visibility]::Visible) + $labelItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + $stackItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + } + + It "shows tweak matches by label tooltip and checkbox content" { + $telemetryItem = New-WinUtilTweakLabelItem -Content "Disable Telemetry" -ToolTip "Stop tracking" + $extensionsItem = New-WinUtilTweakCheckboxItem -Content "Show Extensions" -ToolTip "File extension display" + $nonMatchItem = New-WinUtilTweakLabelItem -Content "Enable NumLock" -ToolTip "Keyboard setting" + $category = New-WinUtilTweakCategory -Label "+ Privacy" -Items @($telemetryItem, $extensionsItem, $nonMatchItem) + $panel = New-WinUtilTweakPanel -Categories @($category) + New-WinUtilTweakSearchContext -TweaksPanel $panel + + Find-TweaksByNameOrDescription -SearchString "tracking" + + $category.Border.Visibility | Should -Be ([Windows.Visibility]::Visible) + $category.Label.Visibility | Should -Be ([Windows.Visibility]::Visible) + $category.Label.Content | Should -Be "- Privacy" + $telemetryItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + $extensionsItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + $nonMatchItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + + Find-TweaksByNameOrDescription -SearchString "Show Extensions" + + $telemetryItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + $extensionsItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + $nonMatchItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + } + + It "hides tweak category panels when no items match" { + $item = New-WinUtilTweakLabelItem -Content "Disable Telemetry" -ToolTip "Stop tracking" + $category = New-WinUtilTweakCategory -Label "- Privacy" -Items @($item) + $panel = New-WinUtilTweakPanel -Categories @($category) + New-WinUtilTweakSearchContext -TweaksPanel $panel + + Find-TweaksByNameOrDescription -SearchString "not-present" + + $category.Border.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + $category.Label.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + $item.Visibility | Should -Be ([Windows.Visibility]::Collapsed) + } + + It "searches the AppX panel when AppX is the current tab" { + $tweakItem = New-WinUtilTweakLabelItem -Content "Disable Telemetry" -ToolTip "Stop tracking" + $appxItem = New-WinUtilTweakCheckboxItem -Content "Xbox Overlay" -ToolTip "Gaming overlay package" + $tweakCategory = New-WinUtilTweakCategory -Label "- Privacy" -Items @($tweakItem) + $appxCategory = New-WinUtilTweakCategory -Label "+ AppX" -Items @($appxItem) + $tweakPanel = New-WinUtilTweakPanel -Categories @($tweakCategory) + $appxPanel = New-WinUtilTweakPanel -Categories @($appxCategory) + New-WinUtilTweakSearchContext -TweaksPanel $tweakPanel -AppxPanel $appxPanel -CurrentTab "AppX" + + Find-TweaksByNameOrDescription -SearchString "overlay" + + $appxCategory.Border.Visibility | Should -Be ([Windows.Visibility]::Visible) + $appxCategory.Label.Content | Should -Be "- AppX" + $appxItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + $tweakCategory.Border.Visibility | Should -Be ([Windows.Visibility]::Visible) + $tweakItem.Visibility | Should -Be ([Windows.Visibility]::Visible) + } +} diff --git a/pester/system-helpers.Tests.ps1 b/pester/system-helpers.Tests.ps1 new file mode 100644 index 00000000..875467cc --- /dev/null +++ b/pester/system-helpers.Tests.ps1 @@ -0,0 +1,157 @@ +#=========================================================================== +# Tests - System Helper Functions +#=========================================================================== + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Set-WinUtilRegistry.ps1") + . (Join-Path $script:repoRoot "functions\private\Set-WinUtilService.ps1") + + function Write-WinUtilLog { } +} + +Describe "Set-WinUtilRegistry" { + BeforeEach { + $script:testPathResults = @{} + + Mock Write-Host { } + Mock Write-Warning { } + Mock Write-WinUtilLog { } + Mock Test-Path { + param([string]$Path) + + if ($script:testPathResults.ContainsKey($Path)) { + return $script:testPathResults[$Path] + } + + throw "Unexpected Test-Path call: $Path" + } + Mock New-PSDrive { } + Mock New-Item { } + Mock Set-ItemProperty { } + Mock Remove-ItemProperty { } + } + + It "creates a missing registry path before setting a value" { + $registryPath = "HKCU:\Software\WinUtilTest" + $script:testPathResults["HKU:\"] = $true + $script:testPathResults[$registryPath] = $false + + Set-WinUtilRegistry -Path $registryPath -Name "Enabled" -Type "DWord" -Value "1" + + Should -Invoke -CommandName New-PSDrive -Times 0 -Exactly + Should -Invoke -CommandName New-Item -Times 1 -Exactly -ParameterFilter { + $Path -eq $registryPath -and $Force -eq $true -and $ErrorAction -eq "Stop" + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq $registryPath -and + $Name -eq "Enabled" -and + $Type -eq "DWord" -and + $Value -eq "1" -and + $Force -eq $true -and + $ErrorAction -eq "Stop" + } + Should -Invoke -CommandName Remove-ItemProperty -Times 0 -Exactly + } + + It "creates the HKU PSDrive when it is missing" { + $registryPath = "HKCU:\Software\WinUtilTest" + $script:testPathResults["HKU:\"] = $false + $script:testPathResults[$registryPath] = $true + + Set-WinUtilRegistry -Path $registryPath -Name "Enabled" -Type "DWord" -Value "1" + + Should -Invoke -CommandName New-PSDrive -Times 1 -Exactly -ParameterFilter { + $PSProvider -eq "Registry" -and + $Name -eq "HKU" -and + $Root -eq "HKEY_USERS" + } + Should -Invoke -CommandName New-Item -Times 0 -Exactly + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq $registryPath -and $Name -eq "Enabled" -and $Type -eq "DWord" -and $Value -eq "1" + } + } + + It "removes a registry value when requested" { + $registryPath = "HKLM:\Software\WinUtilTest" + $script:testPathResults["HKU:\"] = $true + $script:testPathResults[$registryPath] = $true + + Set-WinUtilRegistry -Path $registryPath -Name "ObsoleteValue" -Type "String" -Value "" + + Should -Invoke -CommandName Set-ItemProperty -Times 0 -Exactly + Should -Invoke -CommandName Remove-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq $registryPath -and + $Name -eq "ObsoleteValue" -and + $Force -eq $true -and + $ErrorAction -eq "Stop" + } + } +} + +Describe "Set-WinUtilService" { + BeforeEach { + Mock Write-Host { } + Mock Write-Warning { } + Mock Write-WinUtilLog { } + Mock Get-Service { } + Mock Set-Service { } + } + + It "sets the startup type for an existing service" { + Mock Get-Service { + [pscustomobject]@{ + Name = "DiagTrack" + StartType = "Automatic" + } + } -ParameterFilter { $Name -eq "DiagTrack" -and $ErrorAction -eq "Stop" } + + Set-WinUtilService -Name "DiagTrack" -StartupType "Disabled" + + Should -Invoke -CommandName Get-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "DiagTrack" -and $ErrorAction -eq "Stop" + } + Should -Invoke -CommandName Set-Service -Times 1 -Exactly -ParameterFilter { + $StartupType -eq "Disabled" -and $ErrorAction -eq "Stop" + } + } + + It "does not change a service that already has the requested startup type" { + Mock Get-Service { + [pscustomobject]@{ + Name = "DiagTrack" + StartType = "Disabled" + } + } -ParameterFilter { $Name -eq "DiagTrack" -and $ErrorAction -eq "Stop" } + + Set-WinUtilService -Name "DiagTrack" -StartupType "Disabled" + + Should -Invoke -CommandName Get-Service -Times 1 -Exactly + Should -Invoke -CommandName Set-Service -Times 0 -Exactly + } + + It "does not call Set-Service when the service is missing" { + Mock Get-Service { + $exception = [Microsoft.PowerShell.Commands.ServiceCommandException]::new("Cannot find any service with service name '$Name'.") + $errorRecord = [System.Management.Automation.ErrorRecord]::new( + $exception, + "NoServiceFoundForGivenName,Microsoft.PowerShell.Commands.GetServiceCommand", + [System.Management.Automation.ErrorCategory]::ObjectNotFound, + $Name + ) + throw $errorRecord + } -ParameterFilter { $Name -eq "MissingService" -and $ErrorAction -eq "Stop" } + + Set-WinUtilService -Name "MissingService" -StartupType "Disabled" + + Should -Invoke -CommandName Get-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "MissingService" -and $ErrorAction -eq "Stop" + } + Should -Invoke -CommandName Set-Service -Times 0 -Exactly + Should -Invoke -CommandName Write-Warning -Times 1 -Exactly -ParameterFilter { + $Message -eq "Service MissingService was not found." + } + } +} diff --git a/pester/tweaks.Tests.ps1 b/pester/tweaks.Tests.ps1 new file mode 100644 index 00000000..7eda9d2d --- /dev/null +++ b/pester/tweaks.Tests.ps1 @@ -0,0 +1,231 @@ +#=========================================================================== +# Tests - Tweak Orchestration +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilTweaks.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFtweaksbutton.ps1") + + function Set-WinUtilService { + param($Name, $StartupType) + } + function Set-WinUtilRegistry { + param($Name, $Path, $Type, $Value) + } + function Invoke-WinUtilScript { + param($Name, [scriptblock]$ScriptBlock) + } + function Remove-WinUtilAPPX { + param($Name) + } + function Set-WinUtilDNS { + param($DNSProvider) + } + function Invoke-WPFRunspace { + param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock) + } + function Invoke-WPFUIThread { + param([scriptblock]$ScriptBlock) + } + function Set-WinUtilProgressBar { + param($Label, $Percent) + } + function Write-WinUtilLog { + param($Message, $Level, $Component) + } + + function script:New-WinUtilTweaksConfig { + [pscustomobject]@{ + WPFTweaksExample = [pscustomobject]@{ + service = @( + [pscustomobject]@{ + Name = "DiagTrack" + StartupType = "Disabled" + OriginalType = "Automatic" + } + ) + registry = @( + [pscustomobject]@{ + Path = "HKLM:\Software\WinUtilTest" + Name = "AllowTelemetry" + Type = "DWord" + Value = "0" + OriginalValue = "1" + } + ) + InvokeScript = @("Write-Output 'apply tweak'") + UndoScript = @("Write-Output 'undo tweak'") + appx = @("Microsoft.ExampleApp") + } + WPFTweaksServiceOnly = [pscustomobject]@{ + service = @( + [pscustomobject]@{ + Name = "DiagTrack" + StartupType = "Disabled" + OriginalType = "Automatic" + } + ) + } + } + } +} + +Describe "Invoke-WinUtilTweaks" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + configs = @{ + tweaks = New-WinUtilTweaksConfig + } + }) + + Mock Get-Service { + [pscustomobject]@{ + Name = "DiagTrack" + StartType = "Automatic" + } + } + Mock Set-WinUtilService { } + Mock Set-WinUtilRegistry { } + Mock Invoke-WinUtilScript { } + Mock Remove-WinUtilAPPX { } + Mock Write-WinUtilLog { } + Mock Write-Warning { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + It "dispatches apply actions to service, registry, script, and AppX helpers" { + Invoke-WinUtilTweaks -CheckBox "WPFTweaksExample" + + Should -Invoke -CommandName Get-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "DiagTrack" -and $ErrorAction -eq "Stop" + } + Should -Invoke -CommandName Set-WinUtilService -Times 1 -Exactly -ParameterFilter { + $Name -eq "DiagTrack" -and $StartupType -eq "Disabled" + } + Should -Invoke -CommandName Set-WinUtilRegistry -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\Software\WinUtilTest" -and + $Name -eq "AllowTelemetry" -and + $Type -eq "DWord" -and + $Value -eq "0" + } + Should -Invoke -CommandName Invoke-WinUtilScript -Times 1 -Exactly -ParameterFilter { + $Name -eq "WPFTweaksExample" -and $ScriptBlock.ToString() -eq "Write-Output 'apply tweak'" + } + Should -Invoke -CommandName Remove-WinUtilAPPX -Times 1 -Exactly -ParameterFilter { + $Name -eq "Microsoft.ExampleApp" + } + } + + It "uses original registry values and service startup types in undo mode" { + Invoke-WinUtilTweaks -CheckBox "WPFTweaksExample" -undo $true + + Should -Invoke -CommandName Get-Service -Times 0 -Exactly + Should -Invoke -CommandName Set-WinUtilService -Times 1 -Exactly -ParameterFilter { + $Name -eq "DiagTrack" -and $StartupType -eq "Automatic" + } + Should -Invoke -CommandName Set-WinUtilRegistry -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\Software\WinUtilTest" -and + $Name -eq "AllowTelemetry" -and + $Type -eq "DWord" -and + $Value -eq "1" + } + Should -Invoke -CommandName Invoke-WinUtilScript -Times 1 -Exactly -ParameterFilter { + $Name -eq "WPFTweaksExample" -and $ScriptBlock.ToString() -eq "Write-Output 'undo tweak'" + } + Should -Invoke -CommandName Remove-WinUtilAPPX -Times 0 -Exactly + } + + It "keeps a user-changed service startup type by default" { + Mock Get-Service { + [pscustomobject]@{ + Name = "DiagTrack" + StartType = "Manual" + } + } -ParameterFilter { $Name -eq "DiagTrack" } + + Invoke-WinUtilTweaks -CheckBox "WPFTweaksServiceOnly" + + Should -Invoke -CommandName Get-Service -Times 1 -Exactly + Should -Invoke -CommandName Set-WinUtilService -Times 0 -Exactly + } + + It "forces a service startup type when KeepServiceStartup is disabled" { + Invoke-WinUtilTweaks -CheckBox "WPFTweaksServiceOnly" -KeepServiceStartup $false + + Should -Invoke -CommandName Get-Service -Times 0 -Exactly + Should -Invoke -CommandName Set-WinUtilService -Times 1 -Exactly -ParameterFilter { + $Name -eq "DiagTrack" -and $StartupType -eq "Disabled" + } + } +} + +Describe "Invoke-WPFtweaksbutton" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + ProcessRunning = $false + selectedTweaks = [System.Collections.Generic.List[string]]::new() + WPFchangedns = [pscustomobject]@{ + text = "Cloudflare" + } + }) + + Mock Invoke-WPFRunspace { [pscustomobject]@{ MockHandle = $true } } + Mock Invoke-WinUtilTweaks { } + Mock Invoke-WPFUIThread { } + Mock Set-WinUtilProgressBar { } + Mock Write-WinUtilLog { } + Mock Write-Host { } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + It "passes selected tweaks, DNS provider, and progress counters to the tweak runspace" { + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:sync.selectedTweaks.Add("WPFTweaksServices") + + Invoke-WPFtweaksbutton + + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { + $ParameterList.Count -eq 4 -and + $ParameterList[0][0] -eq "tweaks" -and + $ParameterList[0][1].Count -eq 2 -and + $ParameterList[0][1][0] -eq "WPFTweaksTelemetry" -and + $ParameterList[0][1][1] -eq "WPFTweaksServices" -and + $ParameterList[1][0] -eq "dnsProvider" -and + $ParameterList[1][1] -eq "Cloudflare" -and + $ParameterList[2][0] -eq "completedSteps" -and + $ParameterList[2][1] -eq 0 -and + $ParameterList[3][0] -eq "totalSteps" -and + $ParameterList[3][1] -eq 2 + } + } + + It "runs the restore point first and advances progress before queueing remaining tweaks" { + $script:sync.selectedTweaks.Add("WPFTweaksRestorePoint") + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + + Invoke-WPFtweaksbutton + + Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 1 -Exactly -ParameterFilter { + $CheckBox -eq "WPFTweaksRestorePoint" + } + Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter { + $ParameterList.Count -eq 4 -and + $ParameterList[0][0] -eq "tweaks" -and + $ParameterList[0][1].Count -eq 1 -and + $ParameterList[0][1][0] -eq "WPFTweaksTelemetry" -and + $ParameterList[1][0] -eq "dnsProvider" -and + $ParameterList[1][1] -eq "Cloudflare" -and + $ParameterList[2][0] -eq "completedSteps" -and + $ParameterList[2][1] -eq 1 -and + $ParameterList[3][0] -eq "totalSteps" -and + $ParameterList[3][1] -eq 2 + } + } +} diff --git a/pester/ui-state.Tests.ps1 b/pester/ui-state.Tests.ps1 new file mode 100644 index 00000000..0b2a9488 --- /dev/null +++ b/pester/ui-state.Tests.ps1 @@ -0,0 +1,274 @@ +#=========================================================================== +# Tests - UI Selection and State Helpers +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + + if (-not ("System.Windows.Controls.CheckBox" -as [type])) { + Add-Type @" +namespace Windows +{ + public enum Visibility + { + Visible, + Collapsed + } +} + +namespace System.Windows.Controls +{ + public class CheckBox + { + public bool? IsChecked { get; set; } + } + + public class Label + { + public object Content { get; set; } + } + + public class WrapPanel + { + public global::Windows.Visibility Visibility { get; set; } + } + + public class StackPanel + { + public System.Collections.ArrayList Children { get; } = new System.Collections.ArrayList(); + } +} +"@ + } + + . (Join-Path $script:repoRoot "functions\private\Update-WinUtilSelections.ps1") + . (Join-Path $script:repoRoot "functions\private\Reset-WPFCheckBoxes.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFSelectedCheckboxesUpdate.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFToggleAllCategories.ps1") + + function script:New-WinUtilFakeCheckBox { + param([bool]$IsChecked = $false) + + $checkbox = [System.Windows.Controls.CheckBox]::new() + $checkbox.IsChecked = $IsChecked + $checkbox + } + + function script:New-WinUtilFakeCategory { + param( + [string]$Label, + [Windows.Visibility]$Visibility + ) + + $category = [System.Windows.Controls.StackPanel]::new() + $categoryLabel = [System.Windows.Controls.Label]::new() + $categoryLabel.Content = $Label + $wrapPanel = [System.Windows.Controls.WrapPanel]::new() + $wrapPanel.Visibility = $Visibility + + $null = $category.Children.Add($categoryLabel) + $null = $category.Children.Add($wrapPanel) + + $category + } + + function script:New-WinUtilUiStateTestContext { + $testSync = [Hashtable]::Synchronized(@{ + selectedApps = [System.Collections.Generic.List[string]]::new() + selectedTweaks = [System.Collections.Generic.List[string]]::new() + selectedToggles = [System.Collections.Generic.List[string]]::new() + selectedFeatures = [System.Collections.Generic.List[string]]::new() + selectedAppx = [System.Collections.Generic.List[string]]::new() + configs = @{ + applicationsHashtable = @{ + WPFInstallGit = [pscustomobject]@{ + Content = "Git" + } + } + } + WPFselectedAppsButton = [pscustomobject]@{ + Content = "" + } + selectedAppsstackPanel = [pscustomobject]@{ + Children = [System.Collections.ArrayList]::new() + } + }) + $script:sync = $testSync + $global:sync = $testSync + } + + function Add-SelectedAppsMenuItem { + param($name, $key) + + $null = $global:sync.selectedAppsstackPanel.Children.Add([pscustomobject]@{ + Name = $name + Key = $key + }) + } +} + +Describe "Update-WinUtilSelections" { + BeforeEach { + New-WinUtilUiStateTestContext + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + + It "adds imported checkbox keys to the matching selected lists" { + Update-WinUtilSelections @( + "WPFInstallGit", + "WPFTweaksTelemetry", + "WPFToggleDarkMode", + "WPFFeatureSandbox", + "WPFAppxExample" + ) + + @($script:sync.selectedApps) | Should -Be @("WPFInstallGit") + @($script:sync.selectedTweaks) | Should -Be @("WPFTweaksTelemetry") + @($script:sync.selectedToggles) | Should -Be @("WPFToggleDarkMode") + @($script:sync.selectedFeatures) | Should -Be @("WPFFeatureSandbox") + @($script:sync.selectedAppx) | Should -Be @("WPFAppxExample") + } +} + +Describe "Invoke-WPFSelectedCheckboxesUpdate" { + BeforeEach { + New-WinUtilUiStateTestContext + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + + It "adds each checkbox family without duplicating existing selections" { + Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName "WPFInstallGit" + Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName "WPFInstallGit" + Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName "WPFTweaksTelemetry" + Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName "WPFToggleDarkMode" + Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName "WPFFeatureSandbox" + Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName "WPFAppxExample" + + @($script:sync.selectedApps) | Should -Be @("WPFInstallGit") + @($script:sync.selectedTweaks) | Should -Be @("WPFTweaksTelemetry") + @($script:sync.selectedToggles) | Should -Be @("WPFToggleDarkMode") + @($script:sync.selectedFeatures) | Should -Be @("WPFFeatureSandbox") + @($script:sync.selectedAppx) | Should -Be @("WPFAppxExample") + } + + It "removes checkbox keys from the matching selected lists" { + $script:sync.selectedApps.Add("WPFInstallGit") + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:sync.selectedToggles.Add("WPFToggleDarkMode") + $script:sync.selectedFeatures.Add("WPFFeatureSandbox") + $script:sync.selectedAppx.Add("WPFAppxExample") + + Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName "WPFInstallGit" + Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName "WPFTweaksTelemetry" + Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName "WPFToggleDarkMode" + Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName "WPFFeatureSandbox" + Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName "WPFAppxExample" + + $script:sync.selectedApps.Count | Should -Be 0 + $script:sync.selectedTweaks.Count | Should -Be 0 + $script:sync.selectedToggles.Count | Should -Be 0 + $script:sync.selectedFeatures.Count | Should -Be 0 + $script:sync.selectedAppx.Count | Should -Be 0 + } +} + +Describe "Reset-WPFCheckBoxes" { + BeforeEach { + New-WinUtilUiStateTestContext + + $script:sync.selectedApps.Add("WPFInstallGit") + $script:sync.selectedTweaks.Add("WPFTweaksTelemetry") + $script:sync.selectedFeatures.Add("WPFFeatureSandbox") + $script:sync.selectedAppx.Add("WPFAppxExample") + $script:sync.selectedToggles.Add("WPFToggleDarkMode") + + $script:sync.WPFInstallGit = New-WinUtilFakeCheckBox + $script:sync.WPFInstallVlc = New-WinUtilFakeCheckBox -IsChecked $true + $script:sync.WPFTweaksTelemetry = New-WinUtilFakeCheckBox + $script:sync.WPFFeatureSandbox = New-WinUtilFakeCheckBox + $script:sync.WPFAppxExample = New-WinUtilFakeCheckBox + $script:sync.WPFToggleDarkMode = New-WinUtilFakeCheckBox + $script:sync.WPFToggleOther = New-WinUtilFakeCheckBox -IsChecked $true + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + + It "syncs non-toggle checkboxes from selected lists and updates selected app UI state" { + Reset-WPFCheckBoxes + + $script:sync.WPFInstallGit.IsChecked | Should -BeTrue + $script:sync.WPFInstallVlc.IsChecked | Should -BeFalse + $script:sync.WPFTweaksTelemetry.IsChecked | Should -BeTrue + $script:sync.WPFFeatureSandbox.IsChecked | Should -BeTrue + $script:sync.WPFAppxExample.IsChecked | Should -BeTrue + $script:sync.WPFToggleDarkMode.IsChecked | Should -BeFalse + $script:sync.WPFselectedAppsButton.Content | Should -Be "Selected Apps: 1" + $script:sync.selectedAppsstackPanel.Children.Count | Should -Be 1 + $script:sync.selectedAppsstackPanel.Children[0].Name | Should -Be "Git" + $script:sync.selectedAppsstackPanel.Children[0].Key | Should -Be "WPFInstallGit" + } + + It "restores imported toggles when requested without changing absent toggles" { + Reset-WPFCheckBoxes -doToggles $true + + $script:sync.WPFToggleDarkMode.IsChecked | Should -BeTrue + $script:sync.WPFToggleOther.IsChecked | Should -BeTrue + } +} + +Describe "Invoke-WPFToggleAllCategories" { + BeforeEach { + New-WinUtilUiStateTestContext + + $script:sync.ItemsControl = [pscustomobject]@{ + Items = [System.Collections.ArrayList]::new() + } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } + + It "expands all install categories and updates collapsed labels" { + $category = New-WinUtilFakeCategory -Label "+ Browsers" -Visibility ([Windows.Visibility]::Collapsed) + $null = $script:sync.ItemsControl.Items.Add($category) + + Invoke-WPFToggleAllCategories -Action "Expand" + + $category.Children[1].Visibility | Should -Be ([Windows.Visibility]::Visible) + $category.Children[0].Content | Should -Be "- Browsers" + } + + It "collapses all install categories and updates expanded labels" { + $category = New-WinUtilFakeCategory -Label "- Browsers" -Visibility ([Windows.Visibility]::Visible) + $null = $script:sync.ItemsControl.Items.Add($category) + + Invoke-WPFToggleAllCategories -Action "Collapse" + + $category.Children[1].Visibility | Should -Be ([Windows.Visibility]::Collapsed) + $category.Children[0].Content | Should -Be "+ Browsers" + } + + It "warns and exits when ItemsControl is not initialized" { + $script:sync.ItemsControl = $null + Mock Write-Warning { } + + Invoke-WPFToggleAllCategories -Action "Expand" + + Should -Invoke -CommandName Write-Warning -Times 1 -Exactly -ParameterFilter { + $Message -eq "ItemsControl not initialized" + } + } +} diff --git a/pester/update-profiles.Tests.ps1 b/pester/update-profiles.Tests.ps1 new file mode 100644 index 00000000..161b49f0 --- /dev/null +++ b/pester/update-profiles.Tests.ps1 @@ -0,0 +1,294 @@ +#=========================================================================== +# Tests - Windows Update Profiles +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFUpdatesdisable.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFUpdatesdefault.ps1") + . (Join-Path $script:repoRoot "functions\public\Invoke-WPFUpdatessecurity.ps1") + + function Write-WinUtilLog { + param($Message, $Level, $Component) + } + function Get-ScheduledTask { + param($TaskPath) + } + function Disable-ScheduledTask { + param( + [Parameter(ValueFromPipeline = $true)] + $InputObject, + $ErrorAction + ) + process { } + } + function Enable-ScheduledTask { + param( + [Parameter(ValueFromPipeline = $true)] + $InputObject, + $ErrorAction + ) + process { } + } + function secedit { + param( + [Parameter(ValueFromRemainingArguments = $true)] + $Arguments + ) + } + + $script:updateTaskPaths = @( + '\Microsoft\Windows\InstallService\*', + '\Microsoft\Windows\UpdateOrchestrator\*', + '\Microsoft\Windows\UpdateAssistant\*', + '\Microsoft\Windows\WaaSMedic\*', + '\Microsoft\Windows\WindowsUpdate\*', + '\Microsoft\WindowsUpdate\*' + ) +} + +Describe "Invoke-WPFUpdatesdisable" { + BeforeEach { + Mock Write-Host { } + Mock Write-WinUtilLog { } + Mock New-Item { } + Mock Set-ItemProperty { } + Mock Set-Service { } + Mock Stop-Service { } + Mock Remove-Item { } + Mock Get-ScheduledTask { + [pscustomobject]@{ + TaskPath = $TaskPath + } + } + Mock Disable-ScheduledTask { } + } + + It "sets update disable registry policy values" { + Invoke-WPFUpdatesdisable + + Should -Invoke -CommandName New-Item -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -and $Force -eq $true + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -and + $Name -eq "NoAutoUpdate" -and + $Type -eq "DWord" -and + $Value -eq 1 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -and + $Name -eq "AUOptions" -and + $Type -eq "DWord" -and + $Value -eq 1 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\DeliveryOptimization\Config" -and + $Name -eq "DODownloadMode" -and + $Type -eq "DWord" -and + $Value -eq 0 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer" -and + $Name -eq "SettingsPageVisibility" -and + $Value -eq "hide:windowsupdate" + } + } + + It "disables update services and clears the SoftwareDistribution folder" { + Invoke-WPFUpdatesdisable + + Should -Invoke -CommandName Set-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "BITS" -and $StartupType -eq "Disabled" + } + Should -Invoke -CommandName Set-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "wuauserv" -and $StartupType -eq "Disabled" + } + Should -Invoke -CommandName Stop-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "UsoSvc" -and $Force -eq $true + } + Should -Invoke -CommandName Set-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "UsoSvc" -and $StartupType -eq "Disabled" + } + Should -Invoke -CommandName Remove-Item -Times 1 -Exactly -ParameterFilter { + $Path -eq "C:\Windows\SoftwareDistribution\*" -and + $Recurse -eq $true -and + $Force -eq $true + } + } + + It "disables update scheduled task paths" { + Invoke-WPFUpdatesdisable + + foreach ($expectedTaskPath in $script:updateTaskPaths) { + $expected = $expectedTaskPath + Should -Invoke -CommandName Get-ScheduledTask -Times 1 -Exactly -ParameterFilter { + $TaskPath -eq $expected + } + } + Should -Invoke -CommandName Disable-ScheduledTask -Times $script:updateTaskPaths.Count -Exactly + } +} + +Describe "Invoke-WPFUpdatesdefault" { + BeforeEach { + Mock Write-Host { } + Mock Write-WinUtilLog { } + Mock Remove-Item { } + Mock Remove-ItemProperty { } + Mock Set-Service { } + Mock Start-Service { } + Mock Get-ScheduledTask { + [pscustomobject]@{ + TaskPath = $TaskPath + } + } + Mock Enable-ScheduledTask { } + Mock secedit { } + } + + It "removes update policy registry paths and shows the Windows Update settings page" { + Invoke-WPFUpdatesdefault + + $expectedPaths = @( + "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU", + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\DeliveryOptimization", + "HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings", + "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Device Metadata", + "HKLM:\SOFTWARE\Policies\Microsoft\Windows\DriverSearching", + "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" + ) + + foreach ($expectedRegistryPath in $expectedPaths) { + $expected = $expectedRegistryPath + Should -Invoke -CommandName Remove-Item -Times 1 -Exactly -ParameterFilter { + $Path -eq $expected -and $Recurse -eq $true -and $Force -eq $true + } + } + Should -Invoke -CommandName Remove-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer" -and + $Name -eq "SettingsPageVisibility" + } + } + + It "restores update service startup types" { + Invoke-WPFUpdatesdefault + + Should -Invoke -CommandName Set-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "BITS" -and $StartupType -eq "Manual" + } + Should -Invoke -CommandName Set-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "wuauserv" -and $StartupType -eq "Manual" + } + Should -Invoke -CommandName Start-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "UsoSvc" + } + Should -Invoke -CommandName Set-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "UsoSvc" -and $StartupType -eq "Automatic" + } + Should -Invoke -CommandName Set-Service -Times 1 -Exactly -ParameterFilter { + $Name -eq "WaaSMedicSvc" -and $StartupType -eq "Manual" + } + } + + It "enables update scheduled task paths and resets local policy defaults" { + Invoke-WPFUpdatesdefault + + foreach ($expectedTaskPath in $script:updateTaskPaths) { + $expected = $expectedTaskPath + Should -Invoke -CommandName Get-ScheduledTask -Times 1 -Exactly -ParameterFilter { + $TaskPath -eq $expected + } + } + Should -Invoke -CommandName Enable-ScheduledTask -Times $script:updateTaskPaths.Count -Exactly + Should -Invoke -CommandName secedit -Times 1 -Exactly -ParameterFilter { + $Arguments[0] -eq "/configure" -and + $Arguments[1] -eq "/cfg" -and + $Arguments[2] -like "*\inf\defltbase.inf" -and + $Arguments[3] -eq "/db" -and + $Arguments[4] -eq "defltbase.sdb" + } + } +} + +Describe "Invoke-WPFUpdatessecurity" { + BeforeEach { + Mock Write-Host { } + Mock Write-WinUtilLog { } + Mock New-Item { } + Mock Set-ItemProperty { } + } + + It "disables driver metadata and Windows Update driver search" { + Invoke-WPFUpdatessecurity + + Should -Invoke -CommandName New-Item -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Device Metadata" -and $Force -eq $true + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\Device Metadata" -and + $Name -eq "PreventDeviceMetadataFromNetwork" -and + $Type -eq "DWord" -and + $Value -eq 1 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\DriverSearching" -and + $Name -eq "DontPromptForWindowsUpdate" -and + $Type -eq "DWord" -and + $Value -eq 1 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\DriverSearching" -and + $Name -eq "DontSearchWindowsUpdate" -and + $Type -eq "DWord" -and + $Value -eq 1 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\DriverSearching" -and + $Name -eq "DriverUpdateWizardWuSearchEnabled" -and + $Type -eq "DWord" -and + $Value -eq 0 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" -and + $Name -eq "ExcludeWUDriversInQualityUpdate" -and + $Type -eq "DWord" -and + $Value -eq 1 + } + } + + It "sets recommended update deferral and auto-reboot policy values" { + Invoke-WPFUpdatessecurity + + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" -and + $Name -eq "BranchReadinessLevel" -and + $Type -eq "DWord" -and + $Value -eq 20 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" -and + $Name -eq "DeferFeatureUpdatesPeriodInDays" -and + $Type -eq "DWord" -and + $Value -eq 365 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" -and + $Name -eq "DeferQualityUpdatesPeriodInDays" -and + $Type -eq "DWord" -and + $Value -eq 4 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -and + $Name -eq "NoAutoRebootWithLoggedOnUsers" -and + $Type -eq "DWord" -and + $Value -eq 1 + } + Should -Invoke -CommandName Set-ItemProperty -Times 1 -Exactly -ParameterFilter { + $Path -eq "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" -and + $Name -eq "AUPowerManagement" -and + $Type -eq "DWord" -and + $Value -eq 0 + } + } +} diff --git a/pester/win11creator.Tests.ps1 b/pester/win11creator.Tests.ps1 index 5fee297c..1c7d0c26 100644 --- a/pester/win11creator.Tests.ps1 +++ b/pester/win11creator.Tests.ps1 @@ -3,9 +3,47 @@ #=========================================================================== Describe "Win11 Creator setup media" { + BeforeAll { + $script:repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") + $script:isoWorkflowPath = Join-Path $script:repoRoot "functions\private\Invoke-WinUtilISO.ps1" + $script:isoScriptPath = Join-Path $script:repoRoot "functions\private\Invoke-WinUtilISOScript.ps1" + $script:autoUnattendPath = Join-Path $script:repoRoot "tools\autounattend.xml" + + function Get-WinUtilFunctionText { + param ( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$FunctionName + ) + + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $Path), [ref]$tokens, [ref]$errors) + if ($errors.Count -gt 0) { + throw "Unable to parse $Path`: $($errors[0].Message)" + } + + $functionAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $FunctionName + }, $true) + + if (-not $functionAst) { + throw "Unable to find function $FunctionName in $Path." + } + + return $functionAst.Extent.Text + } + + $script:editionIdFunction = Get-WinUtilFunctionText -Path $script:isoWorkflowPath -FunctionName "Get-WinUtilEditionIdFromName" + $script:addDriversFunction = Get-WinUtilFunctionText -Path $script:isoScriptPath -FunctionName "Add-DriversToImage" + $script:answerFileChildElementFunction = Get-WinUtilFunctionText -Path $script:isoScriptPath -FunctionName "Get-WinUtilISOScriptChildElement" + $script:answerFileConversionFunction = Get-WinUtilFunctionText -Path $script:isoScriptPath -FunctionName "ConvertTo-WinUtilISOAnswerFile" + $script:editionConfigFunction = Get-WinUtilFunctionText -Path $script:isoScriptPath -FunctionName "Write-WinUtilISOEditionConfig" + } + It "autounattend template does not force a product key" { - $templatePath = Join-Path $PSScriptRoot "..\tools\autounattend.xml" - [xml]$xml = Get-Content -Path $templatePath -Raw + [xml]$xml = Get-Content -Path $script:autoUnattendPath -Raw $nsMgr = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) $nsMgr.AddNamespace("u", "urn:schemas-microsoft-com:unattend") @@ -30,4 +68,167 @@ Describe "Win11 Creator setup media" { } } } + + It "maps Windows edition names to setup edition IDs" { + . ([scriptblock]::Create($script:editionIdFunction)) + + $cases = @{ + "Windows 11 Home Single Language" = "CoreSingleLanguage" + "Windows 11 Home N" = "CoreN" + "Windows 11 Home" = "Core" + "Windows 11 Pro for Workstations N" = "ProfessionalWorkstationN" + "Windows 11 Pro for Workstations" = "ProfessionalWorkstation" + "Windows 11 Pro Education N" = "ProfessionalEducationN" + "Windows 11 Pro Education" = "ProfessionalEducation" + "Windows 11 Pro N" = "ProfessionalN" + "Windows 11 Pro" = "Professional" + "Windows 11 Education N" = "EducationN" + "Windows 11 Education" = "Education" + "Windows 11 Enterprise LTSC N" = "EnterpriseSN" + "Windows 11 Enterprise LTSC" = "EnterpriseS" + "Windows 11 Enterprise N" = "EnterpriseN" + "Windows 11 Enterprise" = "Enterprise" + } + + foreach ($case in $cases.GetEnumerator()) { + Get-WinUtilEditionIdFromName -EditionName $case.Key | Should -Be $case.Value + } + + Get-WinUtilEditionIdFromName -EditionName "Windows 11 Unknown Edition" | Should -Be "" + } + + It "writes ei.cfg and removes stale PID.txt for selected editions" { + . ([scriptblock]::Create($script:editionConfigFunction)) + + $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoConfig_$([guid]::NewGuid())" + $sourcesDir = Join-Path $contentRoot "sources" + $logs = [System.Collections.Generic.List[string]]::new() + $logger = { param($message) $logs.Add([string]$message) } + + try { + New-Item -Path $sourcesDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $sourcesDir "PID.txt") -Value "stale-key" -Encoding UTF8 + + Write-WinUtilISOEditionConfig -ContentRoot $contentRoot -EditionId "Professional" -Logger $logger + + Test-Path (Join-Path $sourcesDir "PID.txt") | Should -BeFalse + Test-Path (Join-Path $sourcesDir "ei.cfg") | Should -BeTrue + (Get-Content -Path (Join-Path $sourcesDir "ei.cfg")) -join "|" | + Should -Be "[EditionID]|Professional|[Channel]|Retail|[VL]|0" + ($logs -join "|") | Should -Match "Removed sources\\PID\.txt" + ($logs -join "|") | Should -Match "Written sources\\ei\.cfg" + } finally { + Remove-Item -Path $contentRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It "skips ei.cfg when selected edition ID is unknown" { + . ([scriptblock]::Create($script:editionConfigFunction)) + + $contentRoot = Join-Path ([IO.Path]::GetTempPath()) "WinUtilIsoConfig_$([guid]::NewGuid())" + $logs = [System.Collections.Generic.List[string]]::new() + $logger = { param($message) $logs.Add([string]$message) } + + try { + New-Item -Path $contentRoot -ItemType Directory -Force | Out-Null + + Write-WinUtilISOEditionConfig -ContentRoot $contentRoot -EditionId "" -Logger $logger + + Test-Path (Join-Path $contentRoot "sources\ei.cfg") | Should -BeFalse + ($logs -join "|") | Should -Match "selected edition ID is unknown" + } finally { + Remove-Item -Path $contentRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + + It "converts autounattend setup metadata without preserving placeholder product keys" { + . ([scriptblock]::Create($script:answerFileChildElementFunction)) + . ([scriptblock]::Create($script:answerFileConversionFunction)) + + [xml]$templateXml = Get-Content -Path $script:autoUnattendPath -Raw + $unattendNs = "urn:schemas-microsoft-com:unattend" + $nsMgr = New-Object System.Xml.XmlNamespaceManager($templateXml.NameTable) + $nsMgr.AddNamespace("u", $unattendNs) + $nsMgr.AddNamespace("sg", "https://schneegans.de/windows/unattend-generator/") + + $setupComponent = $templateXml.SelectSingleNode('//u:settings[@pass="windowsPE"]/u:component[@name="Microsoft-Windows-Setup"]', $nsMgr) + $userData = $templateXml.CreateElement("UserData", $unattendNs) + $productKey = $templateXml.CreateElement("ProductKey", $unattendNs) + $key = $templateXml.CreateElement("Key", $unattendNs) + $key.InnerText = "00000-00000-00000-00000-00000" + [void]$productKey.AppendChild($key) + [void]$userData.AppendChild($productKey) + [void]$setupComponent.AppendChild($userData) + + $originalSetupFileCount = $templateXml.SelectNodes("//sg:File", $nsMgr).Count + [xml]$converted = ConvertTo-WinUtilISOAnswerFile -XmlContent $templateXml.OuterXml -ImageIndex 3 + $convertedNsMgr = New-Object System.Xml.XmlNamespaceManager($converted.NameTable) + $convertedNsMgr.AddNamespace("u", $unattendNs) + $convertedNsMgr.AddNamespace("sg", "https://schneegans.de/windows/unattend-generator/") + + $converted.DocumentElement.NamespaceURI | Should -Be $unattendNs + $converted.DocumentElement.GetAttribute("xmlns:wcm") | Should -Be "http://schemas.microsoft.com/WMIConfig/2002/State" + $converted.SelectNodes('//u:component[@name="Microsoft-Windows-Setup"]/u:UserData/u:ProductKey', $convertedNsMgr).Count | Should -Be 0 + $converted.SelectSingleNode('//u:ImageInstall/u:OSImage/u:InstallFrom/u:MetaData[u:Key="/IMAGE/INDEX"]/u:Value', $convertedNsMgr).InnerText | Should -Be "3" + $converted.SelectNodes("//sg:File", $convertedNsMgr).Count | Should -Be $originalSetupFileCount + } + + It "logs DISM output for driver injection through a mocked command" { + . ([scriptblock]::Create($script:addDriversFunction)) + + $script:dismArguments = @() + function dism { + param([Parameter(ValueFromRemainingArguments)]$Arguments) + $script:dismArguments = @($Arguments) + "driver one" + "driver two" + } + + $logs = [System.Collections.Generic.List[string]]::new() + Add-DriversToImage -MountPath "C:\Mount" -DriverDir "C:\Drivers" -Label "install" -Logger { + param($message) + $logs.Add([string]$message) + } + + ($script:dismArguments -join "|") | Should -Be "/English|/image:C:\Mount|/Add-Driver|/Driver:C:\Drivers|/Recurse" + ($logs -join "|") | Should -Be " dism[install]: driver one| dism[install]: driver two" + } + + It "keeps driver export and boot.wim injection behind the selected branch" { + $content = Get-Content -Path $script:isoScriptPath -Raw + + foreach ($expectedText in @( + 'if ($InjectCurrentSystemDrivers)', + 'Export-WindowsDriver -Online -Destination $driverExportRoot', + 'Add-DriversToImage -MountPath $ScratchDir -DriverDir $driverExportRoot -Label "install" -Logger $Log', + 'Invoke-BootWimInject -BootWimPath $bootWim -DriverDir $driverExportRoot -Logger $Log', + 'Warning: boot.wim not found - skipping boot.wim driver injection.', + 'Driver injection skipped.' + )) { + $content | Should -Match ([regex]::Escape($expectedText)) + } + } + + It "attempts winget oscdimg install and exits before export when fallback fails" { + $content = Get-Content -Path $script:isoWorkflowPath -Raw + + foreach ($expectedText in @( + 'oscdimg.exe not found. Attempting to install via winget...', + 'Install-WinUtilWinget', + 'Get-Command winget', + 'install -e --id Microsoft.OSCDIMG --accept-package-agreements --accept-source-agreements', + 'oscdimg.exe still not found after install attempt.', + 'oscdimg Not Found' + )) { + $content | Should -Match ([regex]::Escape($expectedText)) + } + + $fallbackIndex = $content.IndexOf('oscdimg.exe not found. Attempting to install via winget...') + $notFoundDialogIndex = $content.IndexOf('oscdimg Not Found', $fallbackIndex) + $runspaceIndex = $content.IndexOf('[Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()', $fallbackIndex) + + $fallbackIndex | Should -BeGreaterThan -1 + $notFoundDialogIndex | Should -BeGreaterThan $fallbackIndex + $runspaceIndex | Should -BeGreaterThan $notFoundDialogIndex + } } diff --git a/pester/xaml.Tests.ps1 b/pester/xaml.Tests.ps1 new file mode 100644 index 00000000..05e35bf2 --- /dev/null +++ b/pester/xaml.Tests.ps1 @@ -0,0 +1,404 @@ +#=========================================================================== +# Tests - XAML Control Wiring +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:configRoot = Join-Path $script:repoRoot "config" + $script:functionRoot = Join-Path $script:repoRoot "functions" + $script:scriptsRoot = Join-Path $script:repoRoot "scripts" + $script:xamlPath = Join-Path $script:repoRoot "xaml\inputXML.xaml" + $script:mainScriptPath = Join-Path $script:scriptsRoot "main.ps1" + $script:buttonScriptPath = Join-Path $script:functionRoot "public\Invoke-WPFButton.ps1" + $script:xamlText = Get-Content -Path $script:xamlPath -Raw + $script:xaml = [xml]$script:xamlText + $script:xamlNamespace = New-Object System.Xml.XmlNamespaceManager -ArgumentList $script:xaml.NameTable + $script:xamlNamespace.AddNamespace("x", "http://schemas.microsoft.com/winfx/2006/xaml") + + function script:Get-WinUtilConfigObject { + param([string]$Name) + + Get-Content -Path (Join-Path $script:configRoot "$Name.json") -Raw | ConvertFrom-Json + } + + function script:Get-WinUtilSourceText { + Get-ChildItem -Path @($script:functionRoot, $script:scriptsRoot) -Filter *.ps1 -Recurse | + ForEach-Object { Get-Content -Path $_.FullName -Raw } | + Out-String + } + + function script:Get-WinUtilTopLevelFunctionNames { + Get-ChildItem -Path $script:functionRoot -Filter *.ps1 -Recurse | ForEach-Object { + $tokens = $null + $syntaxErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$syntaxErrors) + if ($syntaxErrors.Count -ne 0) { + throw ($syntaxErrors | Out-String) + } + + $ast.EndBlock.Statements | + Where-Object { $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] } | + ForEach-Object { $_.Name } + } | Sort-Object -Unique + } + + function script:Get-WinUtilXamlNamedControls { + $nodes = $script:xaml.SelectNodes("//*[@Name or @x:Name]", $script:xamlNamespace) + foreach ($node in $nodes) { + $controlName = $node.GetAttribute("Name") + if ([string]::IsNullOrWhiteSpace($controlName)) { + $controlName = $node.GetAttribute("Name", "http://schemas.microsoft.com/winfx/2006/xaml") + } + + [pscustomobject]@{ + Name = $controlName + Type = $node.LocalName + } + } + } + + function script:Get-WinUtilXamlRuntimeNamedControls { + $nodes = $script:xaml.SelectNodes("//*[@Name]") + foreach ($node in $nodes) { + [pscustomobject]@{ + Name = $node.GetAttribute("Name") + Type = $node.LocalName + } + } + } + + function script:Get-WinUtilGeneratedControlNames { + $names = New-Object System.Collections.Generic.List[string] + foreach ($configName in @("appnavigation", "tweaks", "feature", "appx")) { + $config = Get-WinUtilConfigObject -Name $configName + foreach ($entry in $config.PSObject.Properties.Name) { + $names.Add($entry) + } + } + + $applications = Get-WinUtilConfigObject -Name "applications" + foreach ($entry in $applications.PSObject.Properties.Name) { + $names.Add($entry) + $names.Add(($entry -replace "-", "_")) + } + + $names | Sort-Object -Unique + } + + function script:Get-WinUtilButtonSwitchNames { + $buttonSource = Get-Content -Path $script:buttonScriptPath -Raw + [regex]::Matches($buttonSource, '"(WPF[A-Za-z0-9_]+)"\s*\{') | + ForEach-Object { $_.Groups[1].Value } | + Sort-Object -Unique + } + + function script:Test-WinUtilNameInSet { + param( + [Parameter(Mandatory)] + [string]$Name, + + [Parameter(Mandatory)] + [string[]]$Set + ) + + foreach ($item in $Set) { + if ($item -ieq $Name) { + return $true + } + } + + return $false + } +} + +Describe "XAML document" { + It "loads inputXML.xaml as a WPF window XML document" { + $script:xaml.DocumentElement.LocalName | Should -Be "Window" + } + + It "contains expected core controls" { + $xamlNames = @(Get-WinUtilXamlRuntimeNamedControls | ForEach-Object { $_.Name }) + $requiredControls = @( + "WPFMainGrid", + "NavDockPanel", + "GridBesideNavDockPanel", + "WPFTabNav", + "WPFTab1", + "WPFTab2", + "WPFTab3", + "WPFTab4", + "WPFTab5", + "WPFTab6", + "WPFTab1BT", + "WPFTab2BT", + "WPFTab3BT", + "WPFTab4BT", + "WPFTab5BT", + "WPFTab6BT", + "SearchBar", + "SearchBarClearButton", + "appscategory", + "appspanel", + "tweakspanel", + "featurespanel", + "updatespanel", + "appxpanel", + "WPFstandard", + "WPFminimal", + "WPFAdvanced", + "WPFClearTweaksSelection", + "WPFGetInstalledTweaks", + "WPFTweaksbutton", + "WPFUndoall", + "WPFUpdatesdefault", + "WPFUpdatessecurity", + "WPFUpdatesdisable", + "WPFDefaultAppxSelection", + "WPFGetInstalledAppx", + "WPFSelectAllAppx", + "WPFClearAppxSelection", + "WPFRemoveSelectedAppx" + ) + + $missingControls = @($requiredControls | Where-Object { -not (Test-WinUtilNameInSet -Name $_ -Set $xamlNames) }) + if ($missingControls.Count -gt 0) { + throw ($missingControls -join "`n") + } + } + + It "contains Win11 Creator controls used by the ISO workflow" { + $xamlNames = @(Get-WinUtilXamlRuntimeNamedControls | ForEach-Object { $_.Name }) + $requiredControls = @( + "Win11ISOPanel", + "WPFWin11ISOSelectSection", + "WPFWin11ISOPath", + "WPFWin11ISOBrowseButton", + "WPFWin11ISOFileInfo", + "WPFWin11ISODownloadLink", + "WPFWin11ISOMountSection", + "WPFWin11ISOMountButton", + "WPFWin11ISOInjectDrivers", + "WPFWin11ISOVerifyResultPanel", + "WPFWin11ISOMountDriveLetter", + "WPFWin11ISOArchLabel", + "WPFWin11ISOEditionComboBox", + "WPFWin11ISOModifySection", + "WPFWin11ISOModifyButton", + "WPFWin11ISOOutputSection", + "WPFWin11ISOCleanResetButton", + "WPFWin11ISOChooseISOButton", + "WPFWin11ISOChooseUSBButton", + "WPFWin11ISOOptionUSB", + "WPFWin11ISOUSBDriveComboBox", + "WPFWin11ISORefreshUSBButton", + "WPFWin11ISOWriteUSBButton", + "WPFWin11ISOStatusLog" + ) + + $missingControls = @($requiredControls | Where-Object { -not (Test-WinUtilNameInSet -Name $_ -Set $xamlNames) }) + if ($missingControls.Count -gt 0) { + throw ($missingControls -join "`n") + } + } + + It "defines core tabs in the expected order" { + $tabItems = @($script:xaml.SelectNodes('//*[local-name()="TabControl"][@Name="WPFTabNav"]/*[local-name()="TabItem"]')) + $actualTabs = @($tabItems | ForEach-Object { "$($_.GetAttribute("Name")):$($_.GetAttribute("Header"))" }) + $expectedTabs = @( + "WPFTab1:Install", + "WPFTab2:Tweaks", + "WPFTab3:Config", + "WPFTab4:Updates", + "WPFTab5:Win11ISO", + "WPFTab6:AppX" + ) + + if (@($actualTabs).Count -ne $expectedTabs.Count) { + throw "Expected $($expectedTabs.Count) tabs but found $(@($actualTabs).Count): $($actualTabs -join ', ')" + } + + for ($index = 0; $index -lt $expectedTabs.Count; $index++) { + if ($actualTabs[$index] -ne $expectedTabs[$index]) { + throw "Tab order mismatch at index ${index}: expected $($expectedTabs[$index]), found $($actualTabs[$index])" + } + } + } +} + +Describe "XAML and sync wiring" { + It "wires generated config panels to existing target grids" { + $xamlNames = @(Get-WinUtilXamlRuntimeNamedControls | ForEach-Object { $_.Name }) + $mainLines = Get-Content -Path $script:mainScriptPath + $invalidTargets = New-Object System.Collections.Generic.List[string] + + foreach ($line in $mainLines) { + if ($line.TrimStart().StartsWith("#")) { + continue + } + + $match = [regex]::Match( + $line, + 'Invoke-WPFUIElements\s+-configVariable\s+\$sync\.configs\.([A-Za-z0-9_]+)\s+-targetGridName\s+"([^"]+)"' + ) + if (-not $match.Success) { + continue + } + + $configName = $match.Groups[1].Value + $targetGridName = $match.Groups[2].Value + if (-not (Test-Path -Path (Join-Path $script:configRoot "$configName.json"))) { + $invalidTargets.Add("$configName references missing config file") + } + + if (-not (Test-WinUtilNameInSet -Name $targetGridName -Set $xamlNames)) { + $invalidTargets.Add("$configName target grid '$targetGridName' was not found in XAML") + } + } + + if ($invalidTargets.Count -gt 0) { + throw ($invalidTargets -join "`n") + } + } + + It "references only XAML, generated, or intentionally dynamic sync members" { + $sourceText = Get-WinUtilSourceText + $xamlNames = @(Get-WinUtilXamlRuntimeNamedControls | ForEach-Object { $_.Name }) + $generatedNames = @(Get-WinUtilGeneratedControlNames) + $dynamicStateNames = @( + "Form", + "configs", + "preferences", + "runspace", + "Buttons", + "PSScriptRoot", + "version", + "winutildir", + "logPath", + "transcriptPath", + "ProcessRunning", + "selected", + "selectedAppx", + "selectedApps", + "selectedTweaks", + "selectedToggles", + "selectedFeatures", + "currentTab", + "selectedAppsStackPanel", + "selectedAppsstackPanel", + "selectedAppsPopup", + "appPopup", + "appPopupSelectedApp", + "ItemsControl", + "InstalledPrograms", + "ImportInProgress", + "ScriptsInstallPrograms", + "keys", + "ContainsKey", + "GetEnumerator", + "logorender", + "checkmarkrender", + "warningrender", + "InstallAppAreaBorder", + "InstallAppAreaScrollViewer", + "InstallAppAreaOverlay", + "InstallAppAreaOverlayText", + "ProgressBar", + "progressBarTextBlock", + "Win11ISOImageInfo", + "Win11ISODriveLetter", + "Win11ISOWimPath", + "Win11ISOImagePath", + "Win11ISOModifying", + "Win11ISOWorkDir", + "Win11ISOContentsDir", + "Win11ISOUSBDisks" + ) + $allowedNames = @($xamlNames + $generatedNames + $dynamicStateNames) | Sort-Object -Unique + $bracketReferences = @( + [regex]::Matches($sourceText, '\$sync\[\s*["'']([A-Za-z_][A-Za-z0-9_]*)["'']\s*\]') | + ForEach-Object { $_.Groups[1].Value } + ) + $dotReferences = @( + [regex]::Matches($sourceText, '\$sync\.([A-Za-z_][A-Za-z0-9_]*)') | + ForEach-Object { $_.Groups[1].Value } + ) + $literalReferences = @($bracketReferences + $dotReferences) | Sort-Object -Unique + + $invalidReferences = @( + $literalReferences | Where-Object { -not (Test-WinUtilNameInSet -Name $_ -Set $allowedNames) } + ) + if ($invalidReferences.Count -gt 0) { + throw ($invalidReferences -join "`n") + } + } +} + +Describe "WPF handler wiring" { + It "calls only defined Invoke-WPF functions" { + $sourceText = Get-WinUtilSourceText + $functionNames = @(Get-WinUtilTopLevelFunctionNames) + $featureFunctions = @( + (Get-WinUtilConfigObject -Name "feature").PSObject.Properties | + Where-Object { $_.Value.function -and $_.Value.function -like "Invoke-WPF*" } | + ForEach-Object { $_.Value.function } + ) + $invokedFunctions = @( + [regex]::Matches($sourceText, '\b(Invoke-WPF[A-Za-z0-9]+)\b') | + ForEach-Object { $_.Groups[1].Value } + $featureFunctions + ) | Sort-Object -Unique + + $missingFunctions = @( + $invokedFunctions | Where-Object { -not (Test-WinUtilNameInSet -Name $_ -Set $functionNames) } + ) + if ($missingFunctions.Count -gt 0) { + throw ($missingFunctions -join "`n") + } + } + + It "routes static WPF buttons through Invoke-WPFButton or explicit handlers" { + $runtimeControls = @(Get-WinUtilXamlRuntimeNamedControls) + $buttonControls = @( + $runtimeControls | + Where-Object { $_.Name -like "WPF*" -and $_.Type -in @("Button", "ToggleButton") } + ) + $buttonSwitchNames = @(Get-WinUtilButtonSwitchNames) + $featureNames = @((Get-WinUtilConfigObject -Name "feature").PSObject.Properties.Name) + $mainScript = Get-Content -Path $script:mainScriptPath -Raw + $unhandledButtons = New-Object System.Collections.Generic.List[string] + + foreach ($button in $buttonControls) { + $hasSwitchHandler = (Test-WinUtilNameInSet -Name $button.Name -Set $buttonSwitchNames) -or $button.Name -like "WPFTab?BT" + $hasFeatureHandler = Test-WinUtilNameInSet -Name $button.Name -Set $featureNames + $escapedName = [regex]::Escape($button.Name) + $explicitHandlerPattern = '\$sync\s*(?:\[\s*["'']' + $escapedName + '["'']\s*\]|\.' + $escapedName + ')\.Add_Click' + $hasExplicitHandler = $mainScript -imatch $explicitHandlerPattern + + if (-not ($hasSwitchHandler -or $hasFeatureHandler -or $hasExplicitHandler)) { + $unhandledButtons.Add($button.Name) + } + } + + if ($unhandledButtons.Count -gt 0) { + throw ($unhandledButtons -join "`n") + } + } + + It "invokes existing WPF button names" { + $sourceText = Get-WinUtilSourceText + $xamlNames = @(Get-WinUtilXamlRuntimeNamedControls | ForEach-Object { $_.Name }) + $generatedNames = @(Get-WinUtilGeneratedControlNames) + $validButtonNames = @($xamlNames + $generatedNames) | Sort-Object -Unique + $buttonNames = @( + [regex]::Matches($sourceText, 'Invoke-WPFButton\s+["'']([^"'']+)["'']') | + ForEach-Object { $_.Groups[1].Value } + ) | Sort-Object -Unique + + $missingButtons = @( + $buttonNames | Where-Object { -not (Test-WinUtilNameInSet -Name $_ -Set $validButtonNames) } + ) + if ($missingButtons.Count -gt 0) { + throw ($missingButtons -join "`n") + } + } +} diff --git a/scripts/start.ps1 b/scripts/start.ps1 index e689b493..25e82580 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -81,10 +81,13 @@ $dateTime = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" # Set the path for the winutil directory $winutildir = "$env:LocalAppData\winutil" New-Item $winutildir -ItemType Directory -Force | Out-Null +$sync.winutildir = $winutildir $logdir = "$winutildir\logs" New-Item $logdir -ItemType Directory -Force | Out-Null -Start-Transcript -Path "$logdir\winutil_$dateTime.log" -Append -NoClobber | Out-Null +$sync.logPath = "$logdir\winutil_$dateTime.log" +$sync.transcriptPath = $sync.logPath +Start-Transcript -Path $sync.logPath -Append -NoClobber | Out-Null # Set PowerShell window title $Host.UI.RawUI.WindowTitle = "WinUtil (Admin)"