Expand Pester coverage across install, tweaks, and UI workflows (#4792)

* Harden Pester CI and discard runspace return values

* Add prioritized test backlog

* Add WinUtil action logging

* Add config integrity tests

* Add XAML control wiring tests

* Add registry and service helper tests

* Add package manager tests

* Add runspace behavior tests

* Add tweak orchestration tests

* Add install workflow tests

* Add update profile tests

* Add AppX removal tests

* Log package installer output

* Add UI state helper tests

* Pin Pester test runner version

* Expand Win11 Creator tests

* Add preferences and theme tests

* Add search filter tests

* Add compile contract tests

* Use single WinUtil session log

* Remove installer output logging helper

* Handle locked WinUtil transcript log

* Avoid appending to active transcript log

* Log install uninstall package identities
This commit is contained in:
Chris Titus
2026-07-01 21:49:15 -05:00
committed by GitHub
parent c3b4f85fce
commit 2cb20893fc
44 changed files with 4586 additions and 36 deletions
+5 -2
View File
@@ -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:
+1
View File
@@ -5,6 +5,7 @@ Microsoft.PowerShell.ConsoleHost.dll
winutil.exe.config
winutil.ps1
binary/
testResults.xml
# general software/os specific
desktop.ini
+6 -1
View File
@@ -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.
+2 -2
View File
@@ -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`.
+107
View File
@@ -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
```
+3 -1
View File
@@ -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
@@ -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)"
}
})
}
@@ -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
}
}
}
@@ -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))"
}
@@ -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))"
}
}
@@ -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"
}
@@ -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)"
}
}
@@ -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"
}
+2
View File
@@ -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"
}
+10 -1
View File
@@ -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)"
}
}
@@ -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 "<RemoveEntry>") {
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)"
}
}
+14 -2
View File
@@ -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 {
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)"
}
}
}
+14
View File
@@ -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)
}
+81
View File
@@ -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)"
}
}
+9 -2
View File
@@ -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
}
+11 -3
View File
@@ -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
}
}
}
+3 -3
View File
@@ -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
+14 -6
View File
@@ -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
}
}
}
@@ -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."
}
@@ -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."
}
@@ -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."
}
@@ -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."
}
}
+2
View File
@@ -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."
}
}
+259
View File
@@ -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
}
}
+440
View File
@@ -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")
}
}
}
+414
View File
@@ -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
}
}
+107
View File
@@ -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"'
}
}
+181
View File
@@ -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"
}
}
}
+347
View File
@@ -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")
}
}
}
+168
View File
@@ -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")
}
}
}
+259
View File
@@ -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(?<json>.*?)\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
}
}
}
+446
View File
@@ -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)
}
}
+157
View File
@@ -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 "<RemoveEntry>"
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."
}
}
}
+231
View File
@@ -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
}
}
}
+274
View File
@@ -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"
}
}
}
+294
View File
@@ -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
}
}
}
+203 -2
View File
@@ -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
}
}
+404
View File
@@ -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")
}
}
}
+4 -1
View File
@@ -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)"