mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-08-10 01:51:18 +10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93dc23dd66 | ||
|
|
916dc761ba | ||
|
|
2cb20893fc |
@@ -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:
|
||||
|
||||
@@ -5,6 +5,7 @@ Microsoft.PowerShell.ConsoleHost.dll
|
||||
winutil.exe.config
|
||||
winutil.ps1
|
||||
binary/
|
||||
testResults.xml
|
||||
|
||||
# general software/os specific
|
||||
desktop.ini
|
||||
|
||||
@@ -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,9 @@ 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.
|
||||
- Keep performance tracing helpers under `tools/perf`; include them in generated output only through `Compile.ps1 -Trace`.
|
||||
|
||||
+40
-3
@@ -1,16 +1,46 @@
|
||||
param (
|
||||
[switch]$Run
|
||||
[switch]$Run,
|
||||
[switch]$Trace
|
||||
)
|
||||
|
||||
$OFS = "`r`n"
|
||||
|
||||
function Get-WinUtilTraceStrippedContent {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Content
|
||||
)
|
||||
|
||||
if ($Trace) {
|
||||
return $Content
|
||||
}
|
||||
|
||||
$filteredLines = $Content -split "`r?`n" | Where-Object {
|
||||
$_ -notmatch '^\s*(Start-WinUtilPerformanceTrace|Stop-WinUtilPerformanceTrace|Write-WinUtilPerformanceCheckpoint)\b'
|
||||
}
|
||||
|
||||
$filteredLines -join "`r`n"
|
||||
}
|
||||
|
||||
# Variable to sync between runspaces
|
||||
$sync = [Hashtable]::Synchronized(@{})
|
||||
$sync.configs = @{}
|
||||
|
||||
$script = (Get-Content -Path scripts\start.ps1) -replace '#{replaceme}', (Get-Date -Format 'yy.MM.dd')
|
||||
|
||||
$script += Get-ChildItem -Path functions -Recurse -File | Get-Content -Raw
|
||||
$script += Get-ChildItem -Path functions -Recurse -File | ForEach-Object {
|
||||
Get-WinUtilTraceStrippedContent -Content (Get-Content -Path $_.FullName -Raw)
|
||||
}
|
||||
|
||||
if ($Trace) {
|
||||
$script += Get-ChildItem -Path tools\perf -Filter *.ps1 -File | Get-Content -Raw
|
||||
|
||||
$script += @'
|
||||
$sync.PerformanceTraceEnabled = $true
|
||||
Start-WinUtilPerformanceTrace
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Config load start"
|
||||
'@
|
||||
}
|
||||
|
||||
Get-ChildItem config | ForEach-Object {
|
||||
$obj = Get-Content -Path $_.FullName -Raw | ConvertFrom-Json
|
||||
@@ -27,6 +57,13 @@ Get-ChildItem config | ForEach-Object {
|
||||
|
||||
$sync.configs[$_.BaseName] = $obj
|
||||
$script += "`$sync.configs.$($_.BaseName) = @'`r`n$json`r`n'@ | ConvertFrom-Json"
|
||||
if ($Trace) {
|
||||
$script += "`r`nWrite-WinUtilPerformanceCheckpoint -Name `"Config $($_.BaseName) loaded`""
|
||||
}
|
||||
}
|
||||
|
||||
if ($Trace) {
|
||||
$script += "`r`nWrite-WinUtilPerformanceCheckpoint -Name `"Config load complete`""
|
||||
}
|
||||
|
||||
$xaml = Get-Content -Path xaml\inputXML.xaml -Raw
|
||||
@@ -35,7 +72,7 @@ $script += "`$inputXML = @'`r`n$xaml`r`n'@"
|
||||
$autounattendXml = Get-Content -Path tools\autounattend.xml -Raw
|
||||
$script += "`$WinUtilAutounattendXml = @'`r`n$autounattendXml`r`n'@"
|
||||
|
||||
$script += Get-Content -Path scripts\main.ps1 -Raw
|
||||
$script += Get-WinUtilTraceStrippedContent -Content (Get-Content -Path scripts\main.ps1 -Raw)
|
||||
|
||||
Set-Content -Path winutil.ps1 -Value $script
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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,18 @@
|
||||
function Close-WinUtilRunspacePool {
|
||||
if ($null -eq $sync -or -not $sync.ContainsKey("runspace") -or $null -eq $sync.runspace) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if ($sync.runspace.RunspacePoolStateInfo.State -notin @(
|
||||
[System.Management.Automation.Runspaces.RunspacePoolState]::Closed,
|
||||
[System.Management.Automation.Runspaces.RunspacePoolState]::Closing,
|
||||
[System.Management.Automation.Runspaces.RunspacePoolState]::Broken
|
||||
)) {
|
||||
$sync.runspace.Close()
|
||||
}
|
||||
} finally {
|
||||
$sync.runspace.Dispose()
|
||||
$sync.Remove("runspace")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,29 +2,39 @@ Function Get-WinUtilToggleStatus ($ToggleSwitch) {
|
||||
|
||||
$ToggleSwitchReg = $sync.configs.tweaks.$ToggleSwitch.registry
|
||||
|
||||
if ($null -eq $sync.ToggleStatusCache) {
|
||||
$sync.ToggleStatusCache = @{}
|
||||
}
|
||||
|
||||
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
|
||||
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
|
||||
}
|
||||
|
||||
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
|
||||
New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null
|
||||
}
|
||||
|
||||
foreach ($regentry in $ToggleSwitchReg) {
|
||||
|
||||
if (-not (Test-Path $regentry.Path)) {
|
||||
New-Item -Path $regentry.Path -Force | Out-Null
|
||||
if (Test-Path $regentry.Path) {
|
||||
$regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name)
|
||||
} else {
|
||||
$regstate = $null
|
||||
}
|
||||
|
||||
$regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name)
|
||||
|
||||
if ($null -eq $regstate) {
|
||||
switch ($regentry.DefaultState) {
|
||||
switch ([string]$regentry.DefaultState) {
|
||||
"true" { $regstate = $regentry.Value }
|
||||
"false" { $regstate = $regentry.OriginalValue }
|
||||
}
|
||||
}
|
||||
|
||||
if ($regstate -ne $regentry.Value) {
|
||||
$sync.ToggleStatusCache[$ToggleSwitch] = $false
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$sync.ToggleStatusCache[$ToggleSwitch] = $true
|
||||
return $true
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ function Initialize-InstallAppEntry {
|
||||
$appKey
|
||||
)
|
||||
|
||||
$app = $sync.configs.applicationsHashtable.$appKey
|
||||
|
||||
# Create the outer Border for the application type
|
||||
$border = New-Object Windows.Controls.Border
|
||||
$border.Style = $sync.Form.Resources.AppEntryBorderStyle
|
||||
$border.Tag = $appKey
|
||||
$border.ToolTip = $Apps.$appKey.description
|
||||
$border.ToolTip = $app.description
|
||||
$border.Add_MouseLeftButtonUp({
|
||||
$childCheckbox = ($this.Child | Where-Object {$_.Template.TargetType -eq [System.Windows.Controls.Checkbox]})[0]
|
||||
$childCheckBox.isChecked = -not $childCheckbox.IsChecked
|
||||
@@ -61,10 +63,10 @@ function Initialize-InstallAppEntry {
|
||||
# Create the TextBlock for the application name
|
||||
$appName = New-Object Windows.Controls.TextBlock
|
||||
$appName.Style = $sync.Form.Resources.AppEntryNameStyle
|
||||
$appName.Text = $Apps.$appKey.content
|
||||
$appName.Text = $app.content
|
||||
|
||||
# Add FOSS label after the name if FOSS
|
||||
if ($Apps.$appKey.foss -eq $true) {
|
||||
if ($app.foss -eq $true) {
|
||||
$fossRun = [System.Windows.Documents.Run]::new(" $([char]0x25CF)")
|
||||
$fossRun.Foreground = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(110, 255, 114))
|
||||
$fossRun.FontSize = 11.5
|
||||
@@ -74,10 +76,13 @@ function Initialize-InstallAppEntry {
|
||||
$checkBox.Content = $appName
|
||||
|
||||
# Add accessibility properties to make the elements screen reader friendly
|
||||
$checkBox.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $Apps.$appKey.content)
|
||||
$border.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $Apps.$appKey.content)
|
||||
$checkBox.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content)
|
||||
$border.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content)
|
||||
|
||||
$border.Child = $checkBox
|
||||
if ($sync.selectedApps -contains $appKey) {
|
||||
$checkBox.IsChecked = $true
|
||||
}
|
||||
# Add the border to the corresponding Category
|
||||
$TargetElement.Children.Add($border) | Out-Null
|
||||
return $checkbox
|
||||
|
||||
@@ -16,7 +16,7 @@ function Initialize-InstallCategoryAppList {
|
||||
$Apps
|
||||
)
|
||||
|
||||
# Pre-group apps by category
|
||||
# Pre-group apps by category before creating WPF controls.
|
||||
$appsByCategory = @{}
|
||||
foreach ($appKey in $Apps.Keys) {
|
||||
$category = $Apps.$appKey.Category
|
||||
@@ -25,6 +25,8 @@ function Initialize-InstallCategoryAppList {
|
||||
}
|
||||
$appsByCategory[$category] += $appKey
|
||||
}
|
||||
$sync.InstallAppRenderQueue = [System.Collections.Queue]::new()
|
||||
|
||||
foreach ($category in $($appsByCategory.Keys | Sort-Object)) {
|
||||
# Create a container for category label + apps
|
||||
$categoryContainer = New-Object Windows.Controls.StackPanel
|
||||
@@ -52,10 +54,10 @@ function Initialize-InstallCategoryAppList {
|
||||
|
||||
# Add click handler to toggle category visibility
|
||||
$toggleButton.Add_MouseLeftButtonUp({
|
||||
param($sender, $e)
|
||||
param($categoryToggle)
|
||||
|
||||
# Find the parent StackPanel (categoryContainer)
|
||||
$categoryContainer = $sender.Parent
|
||||
$categoryContainer = $categoryToggle.Parent
|
||||
if ($categoryContainer -and $categoryContainer.Children.Count -ge 2) {
|
||||
# The WrapPanel is the second child
|
||||
$wrapPanel = $categoryContainer.Children[1]
|
||||
@@ -64,11 +66,11 @@ function Initialize-InstallCategoryAppList {
|
||||
if ($wrapPanel.Visibility -eq [Windows.Visibility]::Visible) {
|
||||
$wrapPanel.Visibility = [Windows.Visibility]::Collapsed
|
||||
# Change - to +
|
||||
$sender.Content = $sender.Content -replace "^- ", "+ "
|
||||
$categoryToggle.Content = $categoryToggle.Content -replace "^- ", "+ "
|
||||
} else {
|
||||
$wrapPanel.Visibility = [Windows.Visibility]::Visible
|
||||
# Change + to -
|
||||
$sender.Content = $sender.Content -replace "^\+ ", "- "
|
||||
$categoryToggle.Content = $categoryToggle.Content -replace "^\+ ", "- "
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -89,9 +91,12 @@ function Initialize-InstallCategoryAppList {
|
||||
# Add the entire category container to the target element
|
||||
$null = $TargetElement.Items.Add($categoryContainer)
|
||||
|
||||
# Add apps to the wrap panel
|
||||
$appsByCategory[$category] | Sort-Object | ForEach-Object {
|
||||
$sync.$_ = $(Initialize-InstallAppEntry -TargetElement $wrapPanel -AppKey $_)
|
||||
}
|
||||
$sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{
|
||||
Category = $category
|
||||
TargetElement = $wrapPanel
|
||||
AppKeys = @($appsByCategory[$category] | Sort-Object)
|
||||
})
|
||||
}
|
||||
|
||||
Start-WinUtilInstallAppRendering
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
function Initialize-WinUtilRunspacePool {
|
||||
if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) {
|
||||
return $sync.runspace
|
||||
}
|
||||
|
||||
if ($sync.runspace) {
|
||||
Close-WinUtilRunspacePool
|
||||
}
|
||||
|
||||
# Set the maximum number of threads for the RunspacePool to the number of threads on the machine.
|
||||
$maxthreads = [Math]::Max([int]$env:NUMBER_OF_PROCESSORS, 1)
|
||||
|
||||
# Create a new session state for parsing variables into our runspace.
|
||||
$hashVars = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'sync', $sync, $null
|
||||
$offlineVar = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'PARAM_OFFLINE', $PARAM_OFFLINE, $null
|
||||
$initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
|
||||
|
||||
$initialSessionState.Variables.Add($hashVars)
|
||||
$initialSessionState.Variables.Add($offlineVar)
|
||||
|
||||
# Get every WinUtil/WPF function and add it to the session state.
|
||||
$functions = Get-ChildItem function:\ | Where-Object { $_.Name -imatch 'winutil|WPF' }
|
||||
foreach ($function in $functions) {
|
||||
$functionDefinition = Get-Content function:\$($function.Name)
|
||||
$functionEntry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $functionDefinition
|
||||
$initialSessionState.Commands.Add($functionEntry)
|
||||
}
|
||||
|
||||
$sync.runspace = [runspacefactory]::CreateRunspacePool(
|
||||
1, # Minimum thread count
|
||||
$maxthreads, # Maximum thread count
|
||||
$initialSessionState, # Initial session state
|
||||
$Host # Machine to create runspaces on
|
||||
)
|
||||
|
||||
$sync.runspace.Open()
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Runspace pool initialized"
|
||||
return $sync.runspace
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
function Initialize-WinUtilTabContent {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TabName
|
||||
)
|
||||
|
||||
if ($null -eq $sync.InitializedTabs) {
|
||||
$sync.InitializedTabs = @{}
|
||||
}
|
||||
|
||||
if ($sync.InitializedTabs[$TabName]) {
|
||||
return
|
||||
}
|
||||
|
||||
switch ($TabName) {
|
||||
"Install" {
|
||||
Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1
|
||||
Initialize-WPFUI -targetGridName "appscategory"
|
||||
Write-WinUtilPerformanceCheckpoint -Name "App navigation UI created"
|
||||
|
||||
Initialize-WPFUI -targetGridName "appspanel"
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Install UI created"
|
||||
}
|
||||
"Tweaks" {
|
||||
Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Tweaks UI created"
|
||||
}
|
||||
"Config" {
|
||||
Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Features UI created"
|
||||
}
|
||||
"AppX" {
|
||||
Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2
|
||||
Write-WinUtilPerformanceCheckpoint -Name "AppX UI created"
|
||||
}
|
||||
"Win11 Creator" {
|
||||
if ($sync.Form -and $sync.Form.Dispatcher) {
|
||||
$sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null
|
||||
}
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Win11 ISO tab initialized"
|
||||
}
|
||||
}
|
||||
|
||||
$sync.InitializedTabs[$TabName] = $true
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
function Initialize-WinUtilTaskbarOverlayAssets {
|
||||
param(
|
||||
[bool]$IncludeLogo = $true,
|
||||
[bool]$IncludeStatusAssets = $true
|
||||
)
|
||||
|
||||
if ($IncludeLogo -and -not $sync["logorender"]) {
|
||||
$sync["logorender"] = (Invoke-WinUtilAssets -Type "Logo" -Size 90 -Render)
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Taskbar logo asset rendered"
|
||||
}
|
||||
|
||||
if ($IncludeStatusAssets -and -not $sync["checkmarkrender"]) {
|
||||
$sync["checkmarkrender"] = (Invoke-WinUtilAssets -Type "checkmark" -Size 512 -Render)
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Taskbar checkmark asset rendered"
|
||||
}
|
||||
|
||||
if ($IncludeStatusAssets -and -not $sync["warningrender"]) {
|
||||
$sync["warningrender"] = (Invoke-WinUtilAssets -Type "warning" -Size 512 -Render)
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Taskbar warning asset rendered"
|
||||
}
|
||||
}
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,17 @@ function Invoke-WinUtilAssets {
|
||||
[switch]$render
|
||||
)
|
||||
|
||||
if ($render -and $null -ne $sync) {
|
||||
if ($null -eq $sync.RenderedAssetCache) {
|
||||
$sync.RenderedAssetCache = @{}
|
||||
}
|
||||
|
||||
$cacheKey = "$(([string]$type).ToLowerInvariant())|$Size"
|
||||
if ($sync.RenderedAssetCache.ContainsKey($cacheKey)) {
|
||||
return $sync.RenderedAssetCache[$cacheKey]
|
||||
}
|
||||
}
|
||||
|
||||
# Create the Viewbox and set its size
|
||||
$LogoViewbox = New-Object Windows.Controls.Viewbox
|
||||
$LogoViewbox.Width = $Size
|
||||
@@ -191,6 +202,13 @@ C 21.36,47.14 28.67,50.71 30.01,52.63
|
||||
$bitmapImage.StreamSource = $imageStream
|
||||
$bitmapImage.CacheOption = [Windows.Media.Imaging.BitmapCacheOption]::OnLoad
|
||||
$bitmapImage.EndInit()
|
||||
if ($bitmapImage.CanFreeze) {
|
||||
$bitmapImage.Freeze()
|
||||
}
|
||||
|
||||
if ($null -ne $sync -and $sync.ContainsKey("RenderedAssetCache")) {
|
||||
$sync.RenderedAssetCache[$cacheKey] = $bitmapImage
|
||||
}
|
||||
|
||||
return $bitmapImage
|
||||
} else {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,21 +20,33 @@ Function Set-WinUtilService {
|
||||
)
|
||||
try {
|
||||
Write-Host "Setting Service $Name to $StartupType"
|
||||
Write-WinUtilLog -Component "Service" -Message "Setting service $Name startup type to $StartupType"
|
||||
|
||||
# Check if the service exists
|
||||
$service = Get-Service -Name $Name -ErrorAction Stop
|
||||
|
||||
if (($service.PSObject.Properties.Name -contains "StartType") -and ([string]$service.StartType -eq [string]$StartupType) ) {
|
||||
Write-Host "Service $Name is already set to $StartupType"
|
||||
Write-WinUtilLog -Component "Service" -Message "Service $Name startup type is already $StartupType; no change needed."
|
||||
return
|
||||
}
|
||||
|
||||
# Service exists, proceed with changing properties -- while handling auto delayed start for PWSH 5
|
||||
if (($PSVersionTable.PSVersion.Major -lt 7) -and ($StartupType -eq "AutomaticDelayedStart")) {
|
||||
sc.exe config $Name start=delayed-auto
|
||||
} else {
|
||||
$service | Set-Service -StartupType $StartupType -ErrorAction Stop
|
||||
}
|
||||
} catch [System.ServiceProcess.ServiceNotFoundException] {
|
||||
Write-Warning "Service $Name was not found."
|
||||
Write-WinUtilLog -Component "Service" -Message "Service $Name startup type set to $StartupType"
|
||||
} catch {
|
||||
Write-Warning "Unable to set $Name due to unhandled exception."
|
||||
Write-Warning $_.Exception.Message
|
||||
if ($_.FullyQualifiedErrorId -like "NoServiceFoundForGivenName,*") {
|
||||
Write-Warning "Service $Name was not found."
|
||||
Write-WinUtilLog -Level "WARN" -Component "Service" -Message "Service $Name was not found."
|
||||
} else {
|
||||
Write-Warning "Unable to set $Name due to unhandled exception."
|
||||
Write-Warning $_.Exception.Message
|
||||
Write-WinUtilLog -Level "ERROR" -Component "Service" -Message "Unable to set service $Name to $StartupType`: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -61,12 +61,21 @@ function Set-WinUtilTaskbaritem {
|
||||
if ($overlay) {
|
||||
switch ($overlay) {
|
||||
'logo' {
|
||||
if (-not $sync["logorender"]) {
|
||||
Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $false
|
||||
}
|
||||
$sync["Form"].taskbarItemInfo.Overlay = $sync["logorender"]
|
||||
}
|
||||
'checkmark' {
|
||||
if (-not $sync["checkmarkrender"]) {
|
||||
Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $false -IncludeStatusAssets $true
|
||||
}
|
||||
$sync["Form"].taskbarItemInfo.Overlay = $sync["checkmarkrender"]
|
||||
}
|
||||
'warning' {
|
||||
if (-not $sync["warningrender"]) {
|
||||
Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $false -IncludeStatusAssets $true
|
||||
}
|
||||
$sync["Form"].taskbarItemInfo.Overlay = $sync["warningrender"]
|
||||
}
|
||||
'None' {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
function Invoke-WinUtilInstallAppRenderBatch {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
$CategoryBatch
|
||||
)
|
||||
|
||||
foreach ($appKey in $CategoryBatch.AppKeys) {
|
||||
$sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey
|
||||
}
|
||||
|
||||
if ($sync.currentTab -eq "Install" -and $sync.SearchBar -and -not [string]::IsNullOrWhiteSpace($sync.SearchBar.Text)) {
|
||||
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text
|
||||
}
|
||||
}
|
||||
|
||||
function Complete-WinUtilInstallAppRendering {
|
||||
$sync.InstallAppEntriesRendered = $true
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Install app entries rendered"
|
||||
}
|
||||
|
||||
function Invoke-WinUtilInstallAppRenderNextBatch {
|
||||
if ($sync.InstallAppRenderQueue.Count -gt 0) {
|
||||
$categoryBatch = $sync.InstallAppRenderQueue.Dequeue()
|
||||
Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $categoryBatch
|
||||
}
|
||||
|
||||
if ($sync.InstallAppRenderQueue.Count -gt 0) {
|
||||
$sync.Form.Dispatcher.BeginInvoke(
|
||||
[System.Windows.Threading.DispatcherPriority]::Background,
|
||||
[action]{ Invoke-WinUtilInstallAppRenderNextBatch }
|
||||
) | Out-Null
|
||||
return
|
||||
}
|
||||
|
||||
Complete-WinUtilInstallAppRendering
|
||||
}
|
||||
|
||||
function Start-WinUtilInstallAppRendering {
|
||||
if ($null -eq $sync.InstallAppRenderQueue) {
|
||||
return
|
||||
}
|
||||
|
||||
$sync.InstallAppEntriesRendered = $false
|
||||
|
||||
if ($sync.Form -and $sync.Form.Dispatcher) {
|
||||
$sync.Form.Dispatcher.BeginInvoke(
|
||||
[System.Windows.Threading.DispatcherPriority]::Background,
|
||||
[action]{ Invoke-WinUtilInstallAppRenderNextBatch }
|
||||
) | Out-Null
|
||||
return
|
||||
}
|
||||
|
||||
while ($sync.InstallAppRenderQueue.Count -gt 0) {
|
||||
$categoryBatch = $sync.InstallAppRenderQueue.Dequeue()
|
||||
Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $categoryBatch
|
||||
}
|
||||
|
||||
Complete-WinUtilInstallAppRendering
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,17 +9,20 @@ function Invoke-WPFInstall {
|
||||
|
||||
if($sync.ProcessRunning) {
|
||||
$msg = "[Invoke-WPFInstall] An Install process is currently running."
|
||||
[System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
Show-WinUtilMessage -Message $msg -Title "Winutil" -Button "OK" -Icon "Warning"
|
||||
return
|
||||
}
|
||||
|
||||
if ($PackagesToInstall.Count -eq 0) {
|
||||
$WarningMsg = "Please select the program(s) to install or upgrade."
|
||||
[System.Windows.MessageBox]::Show($WarningMsg, $AppTitle, [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
Show-WinUtilMessage -Message $WarningMsg -Title $AppTitle -Button "OK" -Icon "Warning"
|
||||
return
|
||||
}
|
||||
|
||||
$ManagerPreference = $sync.preferences.packagemanager
|
||||
Write-WinUtilLog -Component "Install" -Message "Install requested for $(@($PackagesToInstall).Count) selected package(s) using preference: $ManagerPreference"
|
||||
$packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToInstall -Preference $ManagerPreference
|
||||
Write-WinUtilLog -Component "Install" -Message "Install selected package(s): $($packageSummary -join '; ')"
|
||||
|
||||
$handle = Invoke-WPFRunspace -ParameterList @(("PackagesToInstall", $PackagesToInstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock {
|
||||
param($PackagesToInstall, $ManagerPreference)
|
||||
@@ -28,6 +31,7 @@ function Invoke-WPFInstall {
|
||||
|
||||
$packagesWinget = $packagesSorted[[PackageManagers]::Winget]
|
||||
$packagesChoco = $packagesSorted[[PackageManagers]::Choco]
|
||||
Write-WinUtilLog -Component "Install" -Message "Install package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)"
|
||||
|
||||
try {
|
||||
$sync.ProcessRunning = $true
|
||||
@@ -44,13 +48,17 @@ function Invoke-WPFInstall {
|
||||
Write-Host "==========================================="
|
||||
Write-Host "-- Installs have finished ---"
|
||||
Write-Host "==========================================="
|
||||
Write-WinUtilLog -Component "Install" -Message "Install workflow completed."
|
||||
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
|
||||
} catch {
|
||||
Hide-WPFInstallAppBusy
|
||||
Write-Host "==========================================="
|
||||
Write-Host "Error: $_"
|
||||
Write-Host "==========================================="
|
||||
Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "Install workflow failed: $($_.Exception.Message)"
|
||||
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
|
||||
} finally {
|
||||
$sync.ProcessRunning = $False
|
||||
}
|
||||
$sync.ProcessRunning = $False
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,30 +30,67 @@ function Invoke-WPFRunspace {
|
||||
$ParameterList
|
||||
)
|
||||
|
||||
if (-not ("WinUtilRunspaceCleanup" -as [type])) {
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Management.Automation;
|
||||
|
||||
public sealed class WinUtilRunspaceCleanupState
|
||||
{
|
||||
public PowerShell PowerShell { get; set; }
|
||||
public IAsyncResult Handle { get; set; }
|
||||
}
|
||||
|
||||
public static class WinUtilRunspaceCleanup
|
||||
{
|
||||
public static void Cleanup(object state, bool timedOut)
|
||||
{
|
||||
var cleanupState = state as WinUtilRunspaceCleanupState;
|
||||
if (cleanupState == null || cleanupState.PowerShell == null || cleanupState.Handle == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cleanupState.PowerShell.EndInvoke(cleanupState.Handle);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
cleanupState.PowerShell.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
||||
}
|
||||
|
||||
Initialize-WinUtilRunspacePool | Out-Null
|
||||
|
||||
# Create a PowerShell instance
|
||||
$script:powershell = [powershell]::Create()
|
||||
$powershell = [powershell]::Create()
|
||||
|
||||
# Add Scriptblock and Arguments to runspace
|
||||
$script:powershell.AddScript($ScriptBlock)
|
||||
$script:powershell.AddArgument($ArgumentList)
|
||||
[void]$powershell.AddScript($ScriptBlock)
|
||||
[void]$powershell.AddArgument($ArgumentList)
|
||||
|
||||
foreach ($parameter in $ParameterList) {
|
||||
$script:powershell.AddParameter($parameter[0], $parameter[1])
|
||||
[void]$powershell.AddParameter($parameter[0], $parameter[1])
|
||||
}
|
||||
|
||||
$script:powershell.RunspacePool = $sync.runspace
|
||||
$powershell.RunspacePool = $sync.runspace
|
||||
|
||||
# Execute the RunspacePool
|
||||
$script:handle = $script:powershell.BeginInvoke()
|
||||
$handle = $powershell.BeginInvoke()
|
||||
|
||||
$cleanupState = [WinUtilRunspaceCleanupState]::new()
|
||||
$cleanupState.PowerShell = $powershell
|
||||
$cleanupState.Handle = $handle
|
||||
$cleanupCallback = [System.Threading.WaitOrTimerCallback][WinUtilRunspaceCleanup]::Cleanup
|
||||
[System.Threading.ThreadPool]::RegisterWaitForSingleObject($handle.AsyncWaitHandle, $cleanupCallback, $cleanupState, -1, $true) | Out-Null
|
||||
|
||||
# Clean up the RunspacePool threads when they are complete, and invoke the garbage collector to clean up the memory
|
||||
if ($script:handle.IsCompleted) {
|
||||
$script:powershell.EndInvoke($script:handle)
|
||||
$script:powershell.Dispose()
|
||||
$sync.runspace.Dispose()
|
||||
$sync.runspace.Close()
|
||||
[System.GC]::Collect()
|
||||
}
|
||||
# Return the handle
|
||||
return $handle
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ function Invoke-WPFTab {
|
||||
}
|
||||
}
|
||||
$sync.currentTab = $sync.$tabNav.Items[$tabNumber].Header
|
||||
Initialize-WinUtilTabContent -TabName $sync.currentTab
|
||||
|
||||
# Always reset the filter for the current tab
|
||||
if ($sync.currentTab -eq "Install") {
|
||||
|
||||
@@ -11,26 +11,29 @@ function Invoke-WPFUnInstall {
|
||||
|
||||
if($sync.ProcessRunning) {
|
||||
$msg = "[Invoke-WPFUnInstall] Install process is currently running"
|
||||
[System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
Show-WinUtilMessage -Message $msg -Title "Winutil" -Button "OK" -Icon "Warning"
|
||||
return
|
||||
}
|
||||
|
||||
if ($PackagesToUninstall.Count -eq 0) {
|
||||
$WarningMsg = "Please select the program(s) to uninstall"
|
||||
[System.Windows.MessageBox]::Show($WarningMsg, $AppTitle, [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
Show-WinUtilMessage -Message $WarningMsg -Title $AppTitle -Button "OK" -Icon "Warning"
|
||||
return
|
||||
}
|
||||
|
||||
$ButtonType = [System.Windows.MessageBoxButton]::YesNo
|
||||
$ButtonType = "YesNo"
|
||||
$MessageboxTitle = "Are you sure?"
|
||||
$Messageboxbody = ("This will uninstall the following applications: `n $($PackagesToUninstall | Select-Object Name, Description| Out-String)")
|
||||
$MessageIcon = [System.Windows.MessageBoxImage]::Information
|
||||
$MessageIcon = "Information"
|
||||
|
||||
$confirm = [System.Windows.MessageBox]::Show($Messageboxbody, $MessageboxTitle, $ButtonType, $MessageIcon)
|
||||
$confirm = Show-WinUtilMessage -Message $Messageboxbody -Title $MessageboxTitle -Button $ButtonType -Icon $MessageIcon
|
||||
|
||||
if($confirm -eq "No") {return}
|
||||
|
||||
$ManagerPreference = $sync.preferences.packagemanager
|
||||
Write-WinUtilLog -Component "Uninstall" -Message "Uninstall requested for $(@($PackagesToUninstall).Count) selected package(s) using preference: $ManagerPreference"
|
||||
$packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToUninstall -Preference $ManagerPreference
|
||||
Write-WinUtilLog -Component "Uninstall" -Message "Uninstall selected package(s): $($packageSummary -join '; ')"
|
||||
|
||||
Invoke-WPFRunspace -ParameterList @(("PackagesToUninstall", $PackagesToUninstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock {
|
||||
param($PackagesToUninstall, $ManagerPreference)
|
||||
@@ -38,6 +41,7 @@ function Invoke-WPFUnInstall {
|
||||
$packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToUninstall -Preference $ManagerPreference
|
||||
$packagesWinget = $packagesSorted[[PackageManagers]::Winget]
|
||||
$packagesChoco = $packagesSorted[[PackageManagers]::Choco]
|
||||
Write-WinUtilLog -Component "Uninstall" -Message "Uninstall package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)"
|
||||
|
||||
try {
|
||||
$sync.ProcessRunning = $true
|
||||
@@ -58,14 +62,18 @@ function Invoke-WPFUnInstall {
|
||||
Write-Host "==========================================="
|
||||
Write-Host "-- Uninstalls have finished ---"
|
||||
Write-Host "==========================================="
|
||||
Write-WinUtilLog -Component "Uninstall" -Message "Uninstall workflow completed."
|
||||
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
|
||||
} catch {
|
||||
Hide-WPFInstallAppBusy
|
||||
Write-Host "==========================================="
|
||||
Write-Host "Error: $_"
|
||||
Write-Host "==========================================="
|
||||
Write-WinUtilLog -Level "ERROR" -Component "Uninstall" -Message "Uninstall workflow failed: $($_.Exception.Message)"
|
||||
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
|
||||
} finally {
|
||||
$sync.ProcessRunning = $False
|
||||
}
|
||||
$sync.ProcessRunning = $False
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
An error has occurred that was not properly handled. Additional information is shown below. The PowerShell process will exit.
|
||||
Unhandled exception. System.ComponentModel.Win32Exception (1816): Not enough quota is available to process this command.
|
||||
at System.Windows.Interop.HwndTarget.UpdateWindowSettings(Boolean enableRenderTarget, Nullable`1 channelSet)
|
||||
at System.Windows.Interop.HwndTarget.UpdateWindowPos(IntPtr lParam)
|
||||
at System.Windows.Interop.HwndTarget.HandleMessage(WindowMessage msg, IntPtr wparam, IntPtr lparam)
|
||||
at System.Windows.Interop.HwndSource.HwndTargetFilterMessage(IntPtr hwnd, Int32 msg, IntPtr wParam, IntPtr lParam, Boolean& handled)
|
||||
at MS.Win32.HwndWrapper.WndProc(IntPtr hwnd, Int32 msg, IntPtr wParam, IntPtr lParam, Boolean& handled)
|
||||
at System.Windows.Threading.ExceptionWrapper.InternalRealCall(Delegate callback, Object args, Int32 numArgs)
|
||||
at System.Windows.Threading.ExceptionWrapper.TryCatchWhen(Object source, Delegate callback, Object args, Int32 numArgs, Delegate catchHandler)
|
||||
at MS.Win32.HwndSubclass.SubclassWndProc(IntPtr hwnd, Int32 msg, IntPtr wParam, IntPtr lParam)
|
||||
|
||||
[process exited with code 3221226525 (0xc000041d)]
|
||||
You can now close this terminal with Ctrl+D, or press Enter to restart.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#===========================================================================
|
||||
# Tests - Asset rendering
|
||||
#===========================================================================
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
}
|
||||
|
||||
Describe "Rendered asset caching" {
|
||||
It "caches rendered bitmap assets by type and size" {
|
||||
$assetScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilAssets.ps1") -Raw
|
||||
|
||||
$assetScript | Should -Match 'RenderedAssetCache'
|
||||
$assetScript | Should -Match '\$cacheKey = "\$\(\(\[string\]\$type\)\.ToLowerInvariant\(\)\)\|\$Size"'
|
||||
$assetScript | Should -Match 'return \$sync\.RenderedAssetCache\[\$cacheKey\]'
|
||||
$assetScript | Should -Match '\$sync\.RenderedAssetCache\[\$cacheKey\] = \$bitmapImage'
|
||||
}
|
||||
|
||||
It "renders only the logo overlay before first paint and defers status overlays" {
|
||||
$mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw
|
||||
|
||||
$mainScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$false'
|
||||
$mainScript | Should -Match 'Dispatcher\.BeginInvoke\(\[System\.Windows\.Threading\.DispatcherPriority\]::Background, \[action\]\{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$false -IncludeStatusAssets \$true \}'
|
||||
$mainScript | Should -Not -Match '\$sync\["checkmarkrender"\] = \(Invoke-WinUtilAssets -Type "checkmark"'
|
||||
$mainScript | Should -Not -Match '\$sync\["warningrender"\] = \(Invoke-WinUtilAssets -Type "warning"'
|
||||
}
|
||||
|
||||
It "lazily creates taskbar overlays before assigning them" {
|
||||
$taskbarScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Set-WinUtilTaskbarItem.ps1") -Raw
|
||||
|
||||
$taskbarScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$false'
|
||||
$taskbarScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$false -IncludeStatusAssets \$true'
|
||||
}
|
||||
|
||||
It "records individual taskbar overlay render checkpoints" {
|
||||
$overlayScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTaskbarOverlayAssets.ps1") -Raw
|
||||
|
||||
$overlayScript | Should -Match 'Taskbar logo asset rendered'
|
||||
$overlayScript | Should -Match 'Taskbar checkmark asset rendered'
|
||||
$overlayScript | Should -Match 'Taskbar warning asset rendered'
|
||||
}
|
||||
}
|
||||
@@ -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,342 @@ 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
|
||||
$tabInitializerScript = Get-Content -Path (Join-Path $script:repoRoot "functions/private/Initialize-WinUtilTabContent.ps1") -Raw
|
||||
$targetGridMatch = [regex]::Match(
|
||||
"$mainScript`n$tabInitializerScript",
|
||||
'Invoke-WPFUIElements\s+-configVariable\s+\$sync\.configs\.appnavigation\s+-targetGridName\s+"([^"]+)"'
|
||||
)
|
||||
|
||||
if (-not $targetGridMatch.Success) {
|
||||
throw "Startup tab initialization 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#===========================================================================
|
||||
# Tests - Install tab rendering
|
||||
#===========================================================================
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
}
|
||||
|
||||
Describe "Install app rendering startup contract" {
|
||||
It "queues app entries after creating category containers" {
|
||||
$categoryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallCategoryAppList.ps1") -Raw
|
||||
|
||||
$categoryScript | Should -Match '\$sync\.InstallAppRenderQueue = \[System\.Collections\.Queue\]::new\(\)'
|
||||
$categoryScript | Should -Match 'Start-WinUtilInstallAppRendering'
|
||||
$categoryScript | Should -Match 'Pre-group apps by category before creating WPF controls'
|
||||
}
|
||||
|
||||
It "renders queued apps through dispatcher callbacks when a form dispatcher exists" {
|
||||
$renderScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") -Raw
|
||||
|
||||
$renderScript | Should -Match 'Dispatcher\.BeginInvoke'
|
||||
$renderScript | Should -Match 'Invoke-WinUtilInstallAppRenderNextBatch'
|
||||
$renderScript | Should -Match 'Initialize-InstallAppEntry'
|
||||
$renderScript | Should -Match 'Install app entries rendered'
|
||||
}
|
||||
|
||||
It "does not use dispatcher timers for deferred install rendering" {
|
||||
$renderScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") -Raw
|
||||
|
||||
$renderScript | Should -Not -Match 'DispatcherTimer'
|
||||
$renderScript | Should -Not -Match '\$timer'
|
||||
$renderScript | Should -Not -Match '\$dispatcherTimer'
|
||||
$renderScript | Should -Not -Match '\$timer\.Stop\(\)'
|
||||
$renderScript | Should -Not -Match '& \$renderCategory'
|
||||
}
|
||||
|
||||
It "drains queued app batches on the WPF dispatcher without timer scope errors" {
|
||||
Add-Type -AssemblyName WindowsBase
|
||||
. (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1")
|
||||
|
||||
$previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue
|
||||
$previousInitializeAppEntry = Get-Item -Path Function:\Initialize-InstallAppEntry -ErrorAction SilentlyContinue
|
||||
$previousSearch = Get-Item -Path Function:\Find-AppsByNameOrDescription -ErrorAction SilentlyContinue
|
||||
$previousCheckpoint = Get-Item -Path Function:\Write-WinUtilPerformanceCheckpoint -ErrorAction SilentlyContinue
|
||||
$errorCountBefore = $global:Error.Count
|
||||
|
||||
try {
|
||||
$global:sync = [Hashtable]::Synchronized(@{})
|
||||
$global:sync.currentTab = "Install"
|
||||
$global:sync.SearchBar = [pscustomobject]@{ Text = "" }
|
||||
$global:sync.Form = [pscustomobject]@{ Dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher }
|
||||
$global:sync.InstallAppRenderQueue = [System.Collections.Queue]::new()
|
||||
|
||||
$renderedApps = [System.Collections.Generic.List[string]]::new()
|
||||
$checkpoints = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
function global:Initialize-InstallAppEntry {
|
||||
param($TargetElement, $AppKey)
|
||||
$renderedApps.Add($AppKey)
|
||||
return "entry:$AppKey"
|
||||
}
|
||||
|
||||
function global:Find-AppsByNameOrDescription {
|
||||
param($SearchString)
|
||||
throw "Search should not run for an empty search box in this test."
|
||||
}
|
||||
|
||||
function global:Write-WinUtilPerformanceCheckpoint {
|
||||
param([string]$Name)
|
||||
$checkpoints.Add($Name)
|
||||
}
|
||||
|
||||
$global:sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ TargetElement = [pscustomobject]@{}; AppKeys = @("AppA", "AppB") })
|
||||
$global:sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{ TargetElement = [pscustomobject]@{}; AppKeys = @("AppC") })
|
||||
|
||||
$frame = New-Object System.Windows.Threading.DispatcherFrame
|
||||
$timeout = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Start-WinUtilInstallAppRendering
|
||||
|
||||
$closeTimer = New-Object System.Windows.Threading.DispatcherTimer
|
||||
$closeTimer.Interval = [TimeSpan]::FromMilliseconds(25)
|
||||
$closeTimer.Add_Tick({
|
||||
param($sender)
|
||||
$timer = [System.Windows.Threading.DispatcherTimer]$sender
|
||||
|
||||
if ($global:sync.InstallAppEntriesRendered -or $timeout.Elapsed.TotalSeconds -gt 5) {
|
||||
$timer.Stop()
|
||||
$frame.Continue = $false
|
||||
}
|
||||
})
|
||||
$closeTimer.Start()
|
||||
|
||||
[System.Windows.Threading.Dispatcher]::PushFrame($frame)
|
||||
|
||||
$global:sync.InstallAppEntriesRendered | Should -BeTrue
|
||||
$global:sync.InstallAppRenderQueue.Count | Should -Be 0
|
||||
@($renderedApps) | Should -Be @("AppA", "AppB", "AppC")
|
||||
@($checkpoints) | Should -Contain "Install app entries rendered"
|
||||
$global:Error.Count | Should -Be $errorCountBefore
|
||||
} finally {
|
||||
if ($previousSync) {
|
||||
Set-Variable -Name sync -Value $previousSync.Value -Scope Global
|
||||
} else {
|
||||
Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
foreach ($functionBackup in @(
|
||||
@{ Name = "Initialize-InstallAppEntry"; Backup = $previousInitializeAppEntry },
|
||||
@{ Name = "Find-AppsByNameOrDescription"; Backup = $previousSearch },
|
||||
@{ Name = "Write-WinUtilPerformanceCheckpoint"; Backup = $previousCheckpoint }
|
||||
)) {
|
||||
if ($functionBackup.Backup) {
|
||||
Set-Item -Path "Function:\$($functionBackup.Name)" -Value $functionBackup.Backup.ScriptBlock
|
||||
} else {
|
||||
Remove-Item -Path "Function:\$($functionBackup.Name)" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It "keeps app-entry metadata lookup independent from the old caller scope" {
|
||||
$entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw
|
||||
|
||||
$entryScript | Should -Match '\$app = \$sync\.configs\.applicationsHashtable\.\$appKey'
|
||||
$entryScript | Should -Not -Match '\$Apps\.\$appKey'
|
||||
}
|
||||
|
||||
It "restores delayed app checkbox state from selected apps" {
|
||||
$entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw
|
||||
|
||||
$entryScript | Should -Match '\$sync\.selectedApps -contains \$appKey'
|
||||
$entryScript | Should -Match '\$checkBox\.IsChecked = \$true'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
#===========================================================================
|
||||
# Tests - Lazy tab initialization
|
||||
#===========================================================================
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
|
||||
function Invoke-WPFUIElements {
|
||||
param($configVariable, [string]$targetGridName, [int]$columncount)
|
||||
}
|
||||
function Initialize-WPFUI {
|
||||
param([string]$TargetGridName)
|
||||
}
|
||||
function Write-WinUtilPerformanceCheckpoint {
|
||||
param([string]$Name)
|
||||
}
|
||||
function Invoke-WinUtilISOCheckExistingWork { }
|
||||
|
||||
. (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1")
|
||||
}
|
||||
|
||||
Describe "Initialize-WinUtilTabContent" {
|
||||
BeforeEach {
|
||||
$script:sync = [Hashtable]::Synchronized(@{
|
||||
configs = @{
|
||||
appnavigation = [pscustomobject]@{}
|
||||
tweaks = [pscustomobject]@{}
|
||||
feature = [pscustomobject]@{}
|
||||
appx = [pscustomobject]@{}
|
||||
}
|
||||
})
|
||||
|
||||
Mock Invoke-WPFUIElements { }
|
||||
Mock Initialize-WPFUI { }
|
||||
Mock Write-WinUtilPerformanceCheckpoint { }
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
It "initializes the install tab once" {
|
||||
Initialize-WinUtilTabContent -TabName "Install"
|
||||
Initialize-WinUtilTabContent -TabName "Install"
|
||||
|
||||
Should -Invoke -CommandName Invoke-WPFUIElements -Times 1 -Exactly -ParameterFilter {
|
||||
$targetGridName -eq "appscategory" -and $columncount -eq 1
|
||||
}
|
||||
Should -Invoke -CommandName Initialize-WPFUI -Times 1 -Exactly -ParameterFilter {
|
||||
$TargetGridName -eq "appscategory"
|
||||
}
|
||||
Should -Invoke -CommandName Initialize-WPFUI -Times 1 -Exactly -ParameterFilter {
|
||||
$TargetGridName -eq "appspanel"
|
||||
}
|
||||
$script:sync.InitializedTabs["Install"] | Should -BeTrue
|
||||
}
|
||||
|
||||
It "initializes deferred config-backed tabs once" {
|
||||
Initialize-WinUtilTabContent -TabName "Tweaks"
|
||||
Initialize-WinUtilTabContent -TabName "Config"
|
||||
Initialize-WinUtilTabContent -TabName "AppX"
|
||||
Initialize-WinUtilTabContent -TabName "Tweaks"
|
||||
Initialize-WinUtilTabContent -TabName "Config"
|
||||
Initialize-WinUtilTabContent -TabName "AppX"
|
||||
|
||||
Should -Invoke -CommandName Invoke-WPFUIElements -Times 1 -Exactly -ParameterFilter {
|
||||
$targetGridName -eq "tweakspanel" -and $columncount -eq 2
|
||||
}
|
||||
Should -Invoke -CommandName Invoke-WPFUIElements -Times 1 -Exactly -ParameterFilter {
|
||||
$targetGridName -eq "featurespanel" -and $columncount -eq 2
|
||||
}
|
||||
Should -Invoke -CommandName Invoke-WPFUIElements -Times 1 -Exactly -ParameterFilter {
|
||||
$targetGridName -eq "appxpanel" -and $columncount -eq 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Startup lazy tab wiring" {
|
||||
It "builds only install tab content before first paint" {
|
||||
$mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw
|
||||
$startupRegion = $mainScript.Substring(0, $mainScript.IndexOf("# Store Form Objects In PowerShell"))
|
||||
|
||||
$startupRegion | Should -Match 'Initialize-WinUtilTabContent -TabName "Install"'
|
||||
$startupRegion | Should -Not -Match 'targetGridName "tweakspanel"'
|
||||
$startupRegion | Should -Not -Match 'targetGridName "featurespanel"'
|
||||
$startupRegion | Should -Not -Match 'targetGridName "appxpanel"'
|
||||
}
|
||||
|
||||
It "initializes tab content when a tab is selected" {
|
||||
$tabScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFTab.ps1") -Raw
|
||||
|
||||
$tabScript | Should -Match 'Initialize-WinUtilTabContent -TabName \$sync\.currentTab'
|
||||
}
|
||||
}
|
||||
@@ -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"'
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#===========================================================================
|
||||
# Tests - Performance tracing
|
||||
#===========================================================================
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
|
||||
. (Join-Path $script:repoRoot "functions\private\Write-WinUtilLog.ps1")
|
||||
. (Join-Path $script:repoRoot "tools\perf\Test-WinUtilPerformanceTrace.ps1")
|
||||
. (Join-Path $script:repoRoot "tools\perf\Write-WinUtilPerformanceCheckpoint.ps1")
|
||||
. (Join-Path $script:repoRoot "tools\perf\Start-WinUtilPerformanceTrace.ps1")
|
||||
. (Join-Path $script:repoRoot "tools\perf\Stop-WinUtilPerformanceTrace.ps1")
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
& (Join-Path $script:repoRoot "Compile.ps1")
|
||||
}
|
||||
|
||||
Describe "WinUtil performance tracing helpers" {
|
||||
BeforeEach {
|
||||
$script:originalPerfEnv = $env:WINUTIL_PERF_LOG
|
||||
Remove-Item Env:\WINUTIL_PERF_LOG -ErrorAction SilentlyContinue
|
||||
$script:sync = [Hashtable]::Synchronized(@{})
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
if ($null -eq $script:originalPerfEnv) {
|
||||
Remove-Item Env:\WINUTIL_PERF_LOG -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:WINUTIL_PERF_LOG = $script:originalPerfEnv
|
||||
}
|
||||
|
||||
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
|
||||
Remove-Variable -Name originalPerfEnv -Scope Script -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
It "is disabled by default" {
|
||||
Test-WinUtilPerformanceTrace | Should -BeFalse
|
||||
}
|
||||
|
||||
It "can be enabled by environment variable" {
|
||||
$env:WINUTIL_PERF_LOG = "1"
|
||||
|
||||
Test-WinUtilPerformanceTrace | Should -BeTrue
|
||||
}
|
||||
|
||||
It "writes startup checkpoints through the normal WinUtil log helper" {
|
||||
$env:WINUTIL_PERF_LOG = "1"
|
||||
Mock Write-WinUtilLog { }
|
||||
|
||||
Start-WinUtilPerformanceTrace
|
||||
Write-WinUtilPerformanceCheckpoint -Name "XAML loaded"
|
||||
Stop-WinUtilPerformanceTrace
|
||||
|
||||
Should -Invoke -CommandName Write-WinUtilLog -Times 3 -Exactly -ParameterFilter {
|
||||
$Component -eq "StartupPerf" -and $Level -eq "DEBUG"
|
||||
}
|
||||
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
|
||||
$Message -like "XAML loaded:*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Startup performance checkpoints" {
|
||||
It "keeps performance tracing out of normal compiled output" {
|
||||
& (Join-Path $script:repoRoot "Compile.ps1")
|
||||
|
||||
$compiledScript = Get-Content -Path (Join-Path $script:repoRoot "winutil.ps1") -Raw
|
||||
|
||||
$compiledScript | Should -Not -Match "Test-WinUtilPerformanceTrace"
|
||||
$compiledScript | Should -Not -Match "Start-WinUtilPerformanceTrace"
|
||||
$compiledScript | Should -Not -Match "Write-WinUtilPerformanceCheckpoint"
|
||||
$compiledScript | Should -Not -Match "Stop-WinUtilPerformanceTrace"
|
||||
$compiledScript | Should -Not -Match "PerformanceTraceEnabled"
|
||||
}
|
||||
|
||||
It "adds config-load checkpoints only to trace compiled output" {
|
||||
$compileScript = Get-Content -Path (Join-Path $script:repoRoot "Compile.ps1") -Raw
|
||||
|
||||
$compileScript | Should -Match '\[switch\]\$Trace'
|
||||
$compileScript | Should -Match "Start-WinUtilPerformanceTrace"
|
||||
$compileScript | Should -Match "Config load start"
|
||||
$compileScript | Should -Match "Config load complete"
|
||||
$compileScript | Should -Match "Config .* loaded"
|
||||
|
||||
& (Join-Path $script:repoRoot "Compile.ps1") -Trace
|
||||
$compiledScript = Get-Content -Path (Join-Path $script:repoRoot "winutil.ps1") -Raw
|
||||
|
||||
$compiledScript | Should -Match "function Test-WinUtilPerformanceTrace"
|
||||
$compiledScript | Should -Match '\$sync\.PerformanceTraceEnabled = \$true'
|
||||
$compiledScript | Should -Match "Config load start"
|
||||
$compiledScript | Should -Match "Config load complete"
|
||||
}
|
||||
|
||||
It "adds runtime checkpoints for startup hotspots" {
|
||||
$mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw
|
||||
$lazyTabScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1") -Raw
|
||||
$runspaceScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1") -Raw
|
||||
$overlayScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTaskbarOverlayAssets.ps1") -Raw
|
||||
$startupText = "$mainScript`n$lazyTabScript`n$runspaceScript`n$overlayScript"
|
||||
|
||||
foreach ($checkpoint in @(
|
||||
"Runspace pool initialized",
|
||||
"XAML loaded",
|
||||
"Theme applied",
|
||||
"Install UI created",
|
||||
"Tweaks UI created",
|
||||
"Features UI created",
|
||||
"AppX UI created",
|
||||
"Taskbar logo asset rendered",
|
||||
"First content rendered"
|
||||
)) {
|
||||
$startupText | Should -Match ([regex]::Escape($checkpoint))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#===========================================================================
|
||||
# Tests - Runspace lifecycle
|
||||
#===========================================================================
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
. (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1")
|
||||
}
|
||||
|
||||
Describe "Initialize-WinUtilRunspacePool" {
|
||||
BeforeEach {
|
||||
$script:sync = [Hashtable]::Synchronized(@{})
|
||||
$script:PARAM_OFFLINE = $false
|
||||
|
||||
function Write-WinUtilPerformanceCheckpoint { param($Name) }
|
||||
Mock Write-WinUtilPerformanceCheckpoint { }
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
Close-WinUtilRunspacePool
|
||||
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
|
||||
Remove-Variable -Name PARAM_OFFLINE -Scope Script -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
It "creates and reuses one open runspace pool" {
|
||||
$firstPool = Initialize-WinUtilRunspacePool
|
||||
$secondPool = Initialize-WinUtilRunspacePool
|
||||
|
||||
$firstPool.RunspacePoolStateInfo.State | Should -Be ([System.Management.Automation.Runspaces.RunspacePoolState]::Opened)
|
||||
[object]::ReferenceEquals($firstPool, $secondPool) | Should -BeTrue
|
||||
Should -Invoke -CommandName Write-WinUtilPerformanceCheckpoint -Times 1 -Exactly -ParameterFilter {
|
||||
$Name -eq "Runspace pool initialized"
|
||||
}
|
||||
}
|
||||
|
||||
It "closes and removes the active runspace pool" {
|
||||
$pool = Initialize-WinUtilRunspacePool
|
||||
|
||||
Close-WinUtilRunspacePool
|
||||
|
||||
$pool.RunspacePoolStateInfo.State | Should -Be ([System.Management.Automation.Runspaces.RunspacePoolState]::Closed)
|
||||
$script:sync.ContainsKey("runspace") | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe "Runspace startup wiring" {
|
||||
It "does not create the GUI runspace pool before automation checks" {
|
||||
$mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw
|
||||
$beforePreset = $mainScript.Substring(0, $mainScript.IndexOf('if ($Preset)'))
|
||||
|
||||
$beforePreset | Should -Not -Match '\[runspacefactory\]::CreateRunspacePool'
|
||||
$beforePreset | Should -Not -Match '\$sync\.runspace\.Open\(\)'
|
||||
}
|
||||
|
||||
It "initializes runspaces synchronously for automation paths and after first render for GUI" {
|
||||
$mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw
|
||||
|
||||
$mainScript | Should -Match 'if \(\$Preset\) \{\s+Initialize-WinUtilRunspacePool'
|
||||
$mainScript | Should -Match 'if \(\$Config\) \{\s+Initialize-WinUtilRunspacePool'
|
||||
$mainScript | Should -Match 'Dispatcher\.BeginInvoke\(\[System\.Windows\.Threading\.DispatcherPriority\]::Background, \[action\]\{ Initialize-WinUtilRunspacePool'
|
||||
$mainScript | Should -Match 'Close-WinUtilRunspacePool'
|
||||
}
|
||||
|
||||
It "creates runspaces on demand before queueing background work" {
|
||||
$runspaceScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") -Raw
|
||||
|
||||
$runspaceScript | Should -Match 'Initialize-WinUtilRunspacePool \| Out-Null'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
#===========================================================================
|
||||
# Tests - Runspace Behavior
|
||||
#===========================================================================
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
. (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1")
|
||||
. (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 Write-WinUtilPerformanceCheckpoint { param($Name) }
|
||||
}
|
||||
|
||||
function script:Clear-WinUtilRunspaceTestContext {
|
||||
if ($script:sync -and $script:sync.runspace) {
|
||||
$script:sync.runspace.Close()
|
||||
$script:sync.runspace.Dispose()
|
||||
}
|
||||
|
||||
Remove-Variable -Name sync -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" {
|
||||
$script:sync.Result = $null
|
||||
|
||||
$handle = Invoke-WPFRunspace -ScriptBlock {
|
||||
Start-Sleep -Milliseconds 100
|
||||
$sync.Result = "no-args|$($sync.Marker)"
|
||||
}
|
||||
|
||||
Assert-WinUtilAsyncHandle -Handle $handle
|
||||
$script:sync.Result | Should -Be "no-args|shared"
|
||||
}
|
||||
|
||||
It "passes one named parameter" {
|
||||
$script:sync.Result = $null
|
||||
|
||||
$handle = Invoke-WPFRunspace -ParameterList @(,("Name", "value")) -ScriptBlock {
|
||||
param([string]$Name)
|
||||
|
||||
Start-Sleep -Milliseconds 100
|
||||
$sync.Result = "Name=$Name"
|
||||
}
|
||||
|
||||
Assert-WinUtilAsyncHandle -Handle $handle
|
||||
$script:sync.Result | Should -Be "Name=value"
|
||||
}
|
||||
|
||||
It "passes multiple named parameters" {
|
||||
$script:sync.Result = $null
|
||||
|
||||
$handle = Invoke-WPFRunspace -ParameterList @(
|
||||
("First", "alpha"),
|
||||
("Second", "beta")
|
||||
) -ScriptBlock {
|
||||
param(
|
||||
[string]$First,
|
||||
[string]$Second
|
||||
)
|
||||
|
||||
Start-Sleep -Milliseconds 100
|
||||
$sync.Result = "$First|$Second|$($sync.Marker)"
|
||||
}
|
||||
|
||||
Assert-WinUtilAsyncHandle -Handle $handle
|
||||
$script:sync.Result | Should -Be "alpha|beta|shared"
|
||||
}
|
||||
|
||||
It "keeps the shared runspace pool usable after scriptblock failures" {
|
||||
$handle = Invoke-WPFRunspace -ScriptBlock {
|
||||
Start-Sleep -Milliseconds 100
|
||||
throw "runspace failure"
|
||||
}
|
||||
|
||||
Assert-WinUtilAsyncHandle -Handle $handle
|
||||
|
||||
$script:sync.Result = $null
|
||||
$secondHandle = Invoke-WPFRunspace -ScriptBlock {
|
||||
$sync.Result = "after-failure"
|
||||
}
|
||||
|
||||
Assert-WinUtilAsyncHandle -Handle $secondHandle
|
||||
$script:sync.Result | Should -Be "after-failure"
|
||||
}
|
||||
|
||||
It "runs multiple queued invocations without shared PowerShell state" {
|
||||
$script:sync.FirstResult = $null
|
||||
$script:sync.SecondResult = $null
|
||||
|
||||
$firstHandle = Invoke-WPFRunspace -ParameterList @(,("Value", "first")) -ScriptBlock {
|
||||
param([string]$Value)
|
||||
|
||||
Start-Sleep -Milliseconds 150
|
||||
$sync.FirstResult = $Value
|
||||
}
|
||||
$secondHandle = Invoke-WPFRunspace -ParameterList @(,("Value", "second")) -ScriptBlock {
|
||||
param([string]$Value)
|
||||
|
||||
$sync.SecondResult = $Value
|
||||
}
|
||||
|
||||
Assert-WinUtilAsyncHandle -Handle $firstHandle
|
||||
Assert-WinUtilAsyncHandle -Handle $secondHandle
|
||||
$script:sync.FirstResult | Should -Be "first"
|
||||
$script:sync.SecondResult | Should -Be "second"
|
||||
}
|
||||
|
||||
It "does not use script-scoped PowerShell or handle state" {
|
||||
$runspaceScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFRunspace.ps1") -Raw
|
||||
|
||||
$runspaceScript | Should -Not -Match '\$script:powershell'
|
||||
$runspaceScript | Should -Not -Match '\$script:handle'
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
#===========================================================================
|
||||
# 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")
|
||||
. (Join-Path $script:repoRoot "functions\private\Close-WinUtilRunspacePool.ps1")
|
||||
. (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilRunspacePool.ps1")
|
||||
|
||||
function Write-WinUtilPerformanceCheckpoint { param($Name) }
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
try {
|
||||
$script:sync.Result = $null
|
||||
$handle = Invoke-WPFRunspace -ArgumentList "argument" -ParameterList @(,("NamedValue", "parameter")) -ScriptBlock {
|
||||
param($ArgumentValue, [string]$NamedValue)
|
||||
|
||||
Start-Sleep -Milliseconds 200
|
||||
$sync.Result = "$ArgumentValue|$NamedValue|$($sync.SmokeValue)"
|
||||
}
|
||||
|
||||
($handle -is [System.IAsyncResult]) | Should -BeTrue
|
||||
($handle -is [array]) | Should -BeFalse
|
||||
$handle.AsyncWaitHandle.WaitOne(5000) | Should -BeTrue
|
||||
$script:sync.Result | Should -Be "argument|parameter|shared"
|
||||
} finally {
|
||||
if ($script:sync -and $script:sync.runspace) {
|
||||
$script:sync.runspace.Close()
|
||||
$script:sync.runspace.Dispose()
|
||||
}
|
||||
|
||||
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#===========================================================================
|
||||
# Tests - Toggle status checks
|
||||
#===========================================================================
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilToggleStatus.ps1")
|
||||
}
|
||||
|
||||
Describe "Get-WinUtilToggleStatus" {
|
||||
BeforeEach {
|
||||
$script:sync = [Hashtable]::Synchronized(@{
|
||||
configs = @{
|
||||
tweaks = [pscustomobject]@{
|
||||
WPFToggleExample = [pscustomobject]@{
|
||||
registry = @(
|
||||
[pscustomobject]@{
|
||||
Path = "HKCU:\Software\WinUtilToggle"
|
||||
Name = "Enabled"
|
||||
Value = "1"
|
||||
OriginalValue = "0"
|
||||
DefaultState = "true"
|
||||
}
|
||||
)
|
||||
}
|
||||
WPFToggleDisabledByDefault = [pscustomobject]@{
|
||||
registry = @(
|
||||
[pscustomobject]@{
|
||||
Path = "HKCU:\Software\WinUtilToggle"
|
||||
Name = "Enabled"
|
||||
Value = "1"
|
||||
OriginalValue = "0"
|
||||
DefaultState = "false"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Mock Get-PSDrive { [pscustomobject]@{ Name = "HKU" } } -ParameterFilter { $Name -eq "HKU" }
|
||||
Mock New-PSDrive { }
|
||||
Mock New-Item { }
|
||||
Mock Test-Path { $false }
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Enabled = "1" } }
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
It "does not create missing registry paths while reading toggle state" {
|
||||
Get-WinUtilToggleStatus "WPFToggleExample" | Should -BeTrue
|
||||
|
||||
Should -Invoke -CommandName Test-Path -Times 1 -Exactly -ParameterFilter {
|
||||
$Path -eq "HKCU:\Software\WinUtilToggle"
|
||||
}
|
||||
Should -Invoke -CommandName New-Item -Times 0 -Exactly
|
||||
Should -Invoke -CommandName Get-ItemProperty -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It "uses configured false default when the registry path is missing" {
|
||||
Get-WinUtilToggleStatus "WPFToggleDisabledByDefault" | Should -BeFalse
|
||||
|
||||
Should -Invoke -CommandName New-Item -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It "caches toggle results for repeated checks" {
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq "HKCU:\Software\WinUtilToggle" }
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Enabled = "1" } } -ParameterFilter {
|
||||
$Path -eq "HKCU:\Software\WinUtilToggle"
|
||||
}
|
||||
|
||||
Get-WinUtilToggleStatus "WPFToggleExample" | Should -BeTrue
|
||||
Get-WinUtilToggleStatus "WPFToggleExample" | Should -BeTrue
|
||||
|
||||
Should -Invoke -CommandName Test-Path -Times 1 -Exactly -ParameterFilter {
|
||||
$Path -eq "HKCU:\Software\WinUtilToggle"
|
||||
}
|
||||
Should -Invoke -CommandName Get-ItemProperty -Times 1 -Exactly -ParameterFilter {
|
||||
$Path -eq "HKCU:\Software\WinUtilToggle"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
#===========================================================================
|
||||
# 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",
|
||||
"Remove",
|
||||
"logorender",
|
||||
"checkmarkrender",
|
||||
"warningrender",
|
||||
"InitializedTabs",
|
||||
"RenderedAssetCache",
|
||||
"ToggleStatusCache",
|
||||
"InstallAppAreaBorder",
|
||||
"InstallAppAreaScrollViewer",
|
||||
"InstallAppAreaOverlay",
|
||||
"InstallAppAreaOverlayText",
|
||||
"InstallAppRenderQueue",
|
||||
"InstallAppEntriesRendered",
|
||||
"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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-62
@@ -29,39 +29,6 @@ public enum PackageManagers
|
||||
}
|
||||
"@
|
||||
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Set the maximum number of threads for the RunspacePool to the number of threads on the machine
|
||||
$maxthreads = [int]$env:NUMBER_OF_PROCESSORS
|
||||
|
||||
# Create a new session state for parsing variables into our runspace
|
||||
$hashVars = New-object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'sync',$sync,$Null
|
||||
$offlineVar = New-object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'PARAM_OFFLINE',$PARAM_OFFLINE,$Null
|
||||
$InitialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
|
||||
|
||||
# Add the variable to the session state
|
||||
$InitialSessionState.Variables.Add($hashVars)
|
||||
$InitialSessionState.Variables.Add($offlineVar)
|
||||
|
||||
# Get every private function and add them to the session state
|
||||
$functions = Get-ChildItem function:\ | Where-Object { $_.Name -imatch 'winutil|WPF' }
|
||||
foreach ($function in $functions) {
|
||||
$functionDefinition = Get-Content function:\$($function.name)
|
||||
$functionEntry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $($function.name), $functionDefinition
|
||||
|
||||
$initialSessionState.Commands.Add($functionEntry)
|
||||
}
|
||||
|
||||
# Create the runspace pool
|
||||
$sync.runspace = [runspacefactory]::CreateRunspacePool(
|
||||
1, # Minimum thread count
|
||||
$maxthreads, # Maximum thread count
|
||||
$InitialSessionState, # Initial session state
|
||||
$Host # Machine to create runspaces on
|
||||
)
|
||||
|
||||
# Open the RunspacePool instance
|
||||
$sync.runspace.Open()
|
||||
|
||||
# Create classes for different exceptions
|
||||
|
||||
class WingetFailedInstall : Exception {
|
||||
@@ -90,10 +57,14 @@ $sync.configs.appxHashtable = @{}
|
||||
$sync.configs.appx.PSObject.Properties | ForEach-Object {
|
||||
$sync.configs.appxHashtable[$_.Name] = $_.Value
|
||||
}
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Config hashtables initialized"
|
||||
|
||||
Set-Preferences
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Preferences loaded"
|
||||
|
||||
if ($Preset) {
|
||||
Initialize-WinUtilRunspacePool | Out-Null
|
||||
|
||||
# Selects the tweaks from $Preset varible
|
||||
Update-WinUtilSelections -flatJson $sync.configs.preset.$Preset
|
||||
|
||||
@@ -101,28 +72,26 @@ if ($Preset) {
|
||||
Invoke-WinUtilAutoRun
|
||||
|
||||
# Cleanup and exit
|
||||
$sync.runspace.Dispose()
|
||||
$sync.runspace.Close()
|
||||
Close-WinUtilRunspacePool
|
||||
[System.GC]::Collect()
|
||||
Stop-Transcript
|
||||
return
|
||||
}
|
||||
|
||||
if ($Config) {
|
||||
Initialize-WinUtilRunspacePool | Out-Null
|
||||
|
||||
Invoke-WPFImpex -type "import" -Config $Config
|
||||
|
||||
Invoke-WinUtilAutoRun
|
||||
|
||||
# Cleanup and exit
|
||||
$sync.runspace.Dispose()
|
||||
$sync.runspace.Close()
|
||||
Close-WinUtilRunspacePool
|
||||
[System.GC]::Collect()
|
||||
Stop-Transcript
|
||||
return
|
||||
}
|
||||
|
||||
$inputXML = $inputXML -replace 'mc:Ignorable="d"', '' -replace "x:N", 'N' -replace '^<Win.*', '<Window'
|
||||
|
||||
[void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')
|
||||
[xml]$XAML = $inputXML
|
||||
|
||||
@@ -132,6 +101,7 @@ $reader = (New-Object System.Xml.XmlNodeReader $xaml)
|
||||
try {
|
||||
$sync["Form"] = [Windows.Markup.XamlReader]::Load( $reader )
|
||||
$readerOperationSuccessful = $true
|
||||
Write-WinUtilPerformanceCheckpoint -Name "XAML loaded"
|
||||
} catch [System.Management.Automation.MethodInvocationException] {
|
||||
Write-Host "We ran into a problem with the XAML code. Check the syntax for this control..." -ForegroundColor Red
|
||||
Write-Host $error[0].Exception.Message -ForegroundColor Red
|
||||
@@ -146,8 +116,7 @@ try {
|
||||
if (-NOT ($readerOperationSuccessful)) {
|
||||
Write-Host "Failed to parse xaml content using Windows.Markup.XamlReader's Load Method." -ForegroundColor Red
|
||||
Write-Host "Quitting WinUtil..." -ForegroundColor Red
|
||||
$sync.runspace.Dispose()
|
||||
$sync.runspace.Close()
|
||||
Close-WinUtilRunspacePool
|
||||
[System.GC]::Collect()
|
||||
exit 1
|
||||
}
|
||||
@@ -181,19 +150,12 @@ $sync.Form.Add_Loaded({
|
||||
})
|
||||
|
||||
Invoke-WinutilThemeChange -theme $sync.preferences.theme
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Theme applied"
|
||||
|
||||
|
||||
# Now call the function with the final merged config
|
||||
Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1
|
||||
Initialize-WPFUI -targetGridName "appscategory"
|
||||
|
||||
Initialize-WPFUI -targetGridName "appspanel"
|
||||
|
||||
Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2
|
||||
|
||||
Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2
|
||||
|
||||
Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2
|
||||
# Build only the default tab before first paint; other tabs initialize on first activation.
|
||||
$sync.InitializedTabs = @{}
|
||||
Initialize-WinUtilTabContent -TabName "Install"
|
||||
|
||||
# Future implementation: Add Windows Version to updates panel
|
||||
#Invoke-WPFUIElements -configVariable $sync.configs.updates -targetGridName "updatespanel" -columncount 1
|
||||
@@ -259,8 +221,8 @@ Set-WinUtilTaskbaritem -state "None"
|
||||
$sync["Form"].title = $sync["Form"].title + " " + $sync.version
|
||||
# Set the commands that will run when the form is closed
|
||||
$sync["Form"].Add_Closing({
|
||||
$sync.runspace.Dispose()
|
||||
$sync.runspace.Close()
|
||||
Stop-WinUtilPerformanceTrace -Name "Window closing"
|
||||
Close-WinUtilRunspacePool
|
||||
[System.GC]::Collect()
|
||||
})
|
||||
|
||||
@@ -328,6 +290,7 @@ $sync["Form"].Add_Deactivated({
|
||||
})
|
||||
|
||||
$sync["Form"].Add_ContentRendered({
|
||||
Write-WinUtilPerformanceCheckpoint -Name "First content rendered"
|
||||
# Load the Windows Forms assembly
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$primaryScreen = [System.Windows.Forms.Screen]::PrimaryScreen
|
||||
@@ -376,6 +339,8 @@ $sync["Form"].Add_ContentRendered({
|
||||
}
|
||||
|
||||
$sync["Form"].Focus()
|
||||
$sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilRunspacePool | Out-Null }) | Out-Null
|
||||
$sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $false -IncludeStatusAssets $true }) | Out-Null
|
||||
})
|
||||
|
||||
# The SearchBarTimer is used to delay the search operation until the user has stopped typing for a short period
|
||||
@@ -420,10 +385,7 @@ $sync["Form"].Add_Loaded({
|
||||
|
||||
$NavLogoPanel = $sync["Form"].FindName("NavLogoPanel")
|
||||
$NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null
|
||||
|
||||
$sync["logorender"] = (Invoke-WinUtilAssets -Type "Logo" -Size 90 -Render)
|
||||
$sync["checkmarkrender"] = (Invoke-WinUtilAssets -Type "checkmark" -Size 512 -Render)
|
||||
$sync["warningrender"] = (Invoke-WinUtilAssets -Type "warning" -Size 512 -Render)
|
||||
Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $false
|
||||
|
||||
Set-WinUtilTaskbaritem -overlay "logo"
|
||||
|
||||
@@ -516,10 +478,6 @@ $sync["FontScalingApplyButton"].Add_Click({
|
||||
|
||||
# ── Win11ISO Tab button handlers ──────────────────────────────────────────────
|
||||
|
||||
$sync["WPFTab5BT"].Add_Click({
|
||||
$sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null
|
||||
})
|
||||
|
||||
$sync["WPFWin11ISOBrowseButton"].Add_Click({
|
||||
Invoke-WinUtilISOBrowse
|
||||
})
|
||||
|
||||
+4
-1
@@ -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)"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Speed TODO
|
||||
|
||||
WinUtil currently does too much work before the first GUI paint. Work from the top down: measure first, improve perceived launch time next, then clean up deeper execution/runspace paths.
|
||||
|
||||
## P0 - Measure First
|
||||
|
||||
- [x] Add temporary startup timing checkpoints with `System.Diagnostics.Stopwatch`.
|
||||
- [x] Measure config embedding/load, runspace pool initialization, XAML load, theme application, install UI creation, tweaks UI creation, feature UI creation, AppX UI creation, and asset rendering.
|
||||
- [x] Record baseline launch timing on a clean run and a warm run.
|
||||
- [x] Keep timing output in the existing WinUtil log/transcript path.
|
||||
- [x] Remove or gate temporary timing noise after the optimization work is validated.
|
||||
|
||||
## P1 - Faster First Paint
|
||||
|
||||
- [x] Show the main window before building every non-visible panel.
|
||||
- [x] Build only the default visible tab during startup.
|
||||
- [x] Move Tweaks, Features, AppX, and Win11 ISO tab initialization to first tab activation.
|
||||
- [x] Add one-time guards so each lazy tab initializes only once.
|
||||
- [x] Preserve current behavior for `-Preset` and `-Config` automation paths.
|
||||
|
||||
## P2 - Install Tab Rendering
|
||||
|
||||
- [x] Reduce up-front creation of all install app entries.
|
||||
- [x] Keep category/app grouping as pure data before touching WPF controls.
|
||||
- [x] Create WPF controls only on the UI thread.
|
||||
- [x] Investigate replacing eager app-entry creation with item templates or incremental category rendering.
|
||||
- [x] Verify search, selected-app counts, right-click popup, install, uninstall, and import/export selection state still work.
|
||||
|
||||
## P3 - Toggle And Registry Startup Cost
|
||||
|
||||
- [x] Stop doing expensive registry-backed toggle status checks while constructing invisible UI.
|
||||
- [x] Defer toggle state reads until the Tweaks tab is first opened.
|
||||
- [x] Review `Get-WinUtilToggleStatus` so a status read does not create missing registry paths unless that behavior is explicitly required.
|
||||
- [x] Batch or cache repeated registry checks where possible.
|
||||
- [x] Verify toggle import, undo, preset, and manual click behavior still match current expectations.
|
||||
|
||||
## P4 - Runspace Cleanup
|
||||
|
||||
- [x] Refactor `Invoke-WPFRunspace` to avoid shared `$script:powershell` and `$script:handle` state for concurrent callers.
|
||||
- [x] Keep the shared runspace pool alive for the app lifetime instead of disposing it from individual queued calls.
|
||||
- [x] Add explicit completion cleanup for each PowerShell instance after `EndInvoke`.
|
||||
- [x] Add focused Pester coverage for multiple queued runspace calls, failures, and cleanup.
|
||||
- [x] Do not parallelize winget/choco package installs by default; package manager locking and prompts make that unsafe.
|
||||
|
||||
## P5 - Defer Runspace Pool Startup
|
||||
|
||||
- [x] Move GUI runspace pool creation after first render when no automation mode is active.
|
||||
- [x] Keep synchronous runspace pool creation for `-Preset` and `-Config`.
|
||||
- [x] Prewarm the pool shortly after `ContentRendered` so later actions do not feel delayed.
|
||||
- [x] Ensure closing the form disposes the pool if it was created.
|
||||
- [x] Verify buttons that queue work create or reuse the pool safely.
|
||||
|
||||
## P6 - Asset And Theme Cost
|
||||
|
||||
- [x] Measure `Invoke-WinUtilAssets -Render` calls during startup.
|
||||
- [x] Cache rendered logo, checkmark, and warning images once per process.
|
||||
- [x] Defer non-critical taskbar overlay assets until after first render if measurable.
|
||||
- [x] Verify taskbar overlay success/error states still display correctly.
|
||||
|
||||
## P7 - Verification
|
||||
|
||||
- [x] Run `.\Compile.ps1`.
|
||||
- [x] Run focused Pester tests for runspace, config, XAML wiring, install workflow, tweaks, and preferences/theme.
|
||||
- [x] Run the full Pester gate before marking speed work complete.
|
||||
- [ ] Manually launch with `.\Compile.ps1 -Run` and verify first paint, default tab, lazy tabs, search, install selection, tweak toggles, AppX tab, and Win11 ISO tab.
|
||||
- [x] Check `git status --short` and do not stage or commit generated `winutil.ps1`.
|
||||
@@ -0,0 +1,14 @@
|
||||
function Start-WinUtilPerformanceTrace {
|
||||
if (-not (Test-WinUtilPerformanceTrace) -or $null -eq $sync) {
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $sync.ContainsKey("PerformanceTrace")) {
|
||||
$sync.PerformanceTrace = @{
|
||||
Stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
LastElapsedMs = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
Write-WinUtilPerformanceCheckpoint -Name "Startup trace started"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
function Stop-WinUtilPerformanceTrace {
|
||||
param(
|
||||
[string]$Name = "Startup trace complete"
|
||||
)
|
||||
|
||||
if (-not (Test-WinUtilPerformanceTrace) -or $null -eq $sync -or -not $sync.ContainsKey("PerformanceTrace")) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-WinUtilPerformanceCheckpoint -Name $Name
|
||||
|
||||
if ($null -ne $sync.PerformanceTrace.Stopwatch) {
|
||||
$sync.PerformanceTrace.Stopwatch.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
function Test-WinUtilPerformanceTrace {
|
||||
$enabledValue = ([string]$env:WINUTIL_PERF_LOG).ToLowerInvariant()
|
||||
if ($enabledValue -in @("1", "true", "yes", "on")) {
|
||||
return $true
|
||||
}
|
||||
|
||||
if ($null -ne $sync -and $sync.ContainsKey("PerformanceTraceEnabled")) {
|
||||
return [bool]$sync.PerformanceTraceEnabled
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
function Write-WinUtilPerformanceCheckpoint {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if (-not (Test-WinUtilPerformanceTrace) -or $null -eq $sync) {
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $sync.ContainsKey("PerformanceTrace") -or $null -eq $sync.PerformanceTrace.Stopwatch) {
|
||||
$sync.PerformanceTrace = @{
|
||||
Stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
LastElapsedMs = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
$elapsedMs = [math]::Round($sync.PerformanceTrace.Stopwatch.Elapsed.TotalMilliseconds, 2)
|
||||
$deltaMs = [math]::Round($elapsedMs - [double]$sync.PerformanceTrace.LastElapsedMs, 2)
|
||||
$sync.PerformanceTrace.LastElapsedMs = $elapsedMs
|
||||
|
||||
Write-WinUtilLog -Level "DEBUG" -Component "StartupPerf" -Message ("{0}: {1:N2} ms (+{2:N2} ms)" -f $Name, $elapsedMs, $deltaMs)
|
||||
}
|
||||
+32
-33
@@ -1,10 +1,9 @@
|
||||
<Window x:Class="WinUtility.MainWindow"
|
||||
<Window
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:WinUtility"
|
||||
mc:Ignorable="d"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
UseLayoutRounding="True"
|
||||
WindowStyle="None"
|
||||
@@ -56,9 +55,9 @@
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type Thumb}">
|
||||
<Grid x:Name="Grid">
|
||||
<Grid Name="Grid">
|
||||
<Rectangle HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Width="Auto" Height="Auto" Fill="Transparent" />
|
||||
<Border x:Name="Rectangle1" CornerRadius="5" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Width="Auto" Height="Auto" Background="{TemplateBinding Background}" />
|
||||
<Border Name="Rectangle1" CornerRadius="5" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Width="Auto" Height="Auto" Background="{TemplateBinding Background}" />
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="Tag" Value="Horizontal">
|
||||
@@ -103,14 +102,14 @@
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Grid Width="16" Height="16" Margin="0,0,8,0">
|
||||
<Border x:Name="CheckBoxBorder"
|
||||
<Border Name="CheckBoxBorder"
|
||||
BorderBrush="{DynamicResource MainForegroundColor}"
|
||||
Background="{DynamicResource ButtonBackgroundColor}"
|
||||
BorderThickness="1"
|
||||
Width="12"
|
||||
Height="12"
|
||||
CornerRadius="2"/>
|
||||
<Path x:Name="CheckMark"
|
||||
<Path Name="CheckMark"
|
||||
Stroke="{DynamicResource ToggleButtonOnColor}"
|
||||
StrokeThickness="2"
|
||||
Data="M 2 8 L 6 12 L 14 4"
|
||||
@@ -159,7 +158,7 @@
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundBorder"
|
||||
<Border Name="BackgroundBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{DynamicResource ButtonBorderThickness}"
|
||||
@@ -219,20 +218,20 @@
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type ScrollBar}">
|
||||
<Grid x:Name="GridRoot" Width="7" Background="{TemplateBinding Background}" >
|
||||
<Grid Name="GridRoot" Width="7" Background="{TemplateBinding Background}" >
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="0.00001*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Track x:Name="PART_Track" Grid.Row="0" IsDirectionReversed="true" Focusable="false">
|
||||
<Track Name="PART_Track" Grid.Row="0" IsDirectionReversed="true" Focusable="false">
|
||||
<Track.Thumb>
|
||||
<Thumb x:Name="Thumb" Background="{TemplateBinding Foreground}" Style="{DynamicResource ScrollThumbs}" />
|
||||
<Thumb Name="Thumb" Background="{TemplateBinding Foreground}" Style="{DynamicResource ScrollThumbs}" />
|
||||
</Track.Thumb>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton x:Name="PageUp" Command="ScrollBar.PageDownCommand" Opacity="0" Focusable="false" />
|
||||
<RepeatButton Name="PageUp" Command="ScrollBar.PageDownCommand" Opacity="0" Focusable="false" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton x:Name="PageDown" Command="ScrollBar.PageUpCommand" Opacity="0" Focusable="false" />
|
||||
<RepeatButton Name="PageDown" Command="ScrollBar.PageUpCommand" Opacity="0" Focusable="false" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
</Track>
|
||||
</Grid>
|
||||
@@ -278,12 +277,12 @@
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ComboBox">
|
||||
<Grid>
|
||||
<Border x:Name="OuterBorder"
|
||||
<Border Name="OuterBorder"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource ButtonCornerRadius}"
|
||||
Background="{TemplateBinding Background}">
|
||||
<ToggleButton x:Name="ToggleButton"
|
||||
<ToggleButton Name="ToggleButton"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
@@ -310,13 +309,13 @@
|
||||
</Grid>
|
||||
</ToggleButton>
|
||||
</Border>
|
||||
<Popup x:Name="Popup"
|
||||
<Popup Name="Popup"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
Placement="Bottom"
|
||||
Focusable="False"
|
||||
AllowsTransparency="True"
|
||||
PopupAnimation="Slide">
|
||||
<Border x:Name="DropDownBorder"
|
||||
<Border Name="DropDownBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{DynamicResource BorderColor}"
|
||||
BorderThickness="1"
|
||||
@@ -352,13 +351,13 @@
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Grid>
|
||||
<Border x:Name="ButtonGlow"
|
||||
<Border Name="ButtonGlow"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{DynamicResource ButtonForegroundColor}"
|
||||
BorderThickness="{DynamicResource ButtonBorderThickness}"
|
||||
CornerRadius="{DynamicResource ButtonCornerRadius}">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundBorder"
|
||||
<Border Name="BackgroundBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{DynamicResource ButtonBackgroundColor}"
|
||||
BorderThickness="{DynamicResource ButtonBorderThickness}"
|
||||
@@ -413,7 +412,7 @@
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundBorder"
|
||||
<Border Name="BackgroundBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{DynamicResource ButtonBorderThickness}"
|
||||
@@ -450,7 +449,7 @@
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Grid>
|
||||
<Border x:Name="BackgroundBorder"
|
||||
<Border Name="BackgroundBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{DynamicResource ButtonBorderThickness}"
|
||||
@@ -464,7 +463,7 @@
|
||||
Margin="0,3,5,0" />
|
||||
|
||||
<!-- Toggle Dot with hover grow effect -->
|
||||
<Ellipse x:Name="ToggleDot"
|
||||
<Ellipse Name="ToggleDot"
|
||||
Width="8" Height="8"
|
||||
Fill="{DynamicResource ButtonForegroundColor}"
|
||||
HorizontalAlignment="Right"
|
||||
@@ -568,7 +567,7 @@
|
||||
<BulletDecorator Background="Transparent">
|
||||
<BulletDecorator.Bullet>
|
||||
<Grid Width="{DynamicResource CheckBoxBulletDecoratorSize}" Height="{DynamicResource CheckBoxBulletDecoratorSize}">
|
||||
<Border x:Name="Border"
|
||||
<Border Name="Border"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
Background="{DynamicResource ButtonBackgroundColor}"
|
||||
BorderThickness="1"
|
||||
@@ -576,13 +575,13 @@
|
||||
Height="{DynamicResource CheckBoxBulletDecoratorSize *0.85}"
|
||||
Margin="1"
|
||||
SnapsToDevicePixels="True"/>
|
||||
<Viewbox x:Name="CheckMarkContainer"
|
||||
<Viewbox Name="CheckMarkContainer"
|
||||
Width="{DynamicResource CheckBoxBulletDecoratorSize}"
|
||||
Height="{DynamicResource CheckBoxBulletDecoratorSize}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="Collapsed">
|
||||
<Path x:Name="CheckMark"
|
||||
<Path Name="CheckMark"
|
||||
Stroke="{DynamicResource ToggleButtonOnColor}"
|
||||
StrokeThickness="1.5"
|
||||
Data="M 0 5 L 5 10 L 12 0"
|
||||
@@ -620,14 +619,14 @@
|
||||
<StackPanel Orientation="Horizontal" Margin="{DynamicResource CheckBoxMargin}">
|
||||
<Viewbox Width="{DynamicResource CheckBoxBulletDecoratorSize}" Height="{DynamicResource CheckBoxBulletDecoratorSize}">
|
||||
<Grid Width="14" Height="14">
|
||||
<Ellipse x:Name="OuterCircle"
|
||||
<Ellipse Name="OuterCircle"
|
||||
Stroke="{DynamicResource ToggleButtonOffColor}"
|
||||
Fill="{DynamicResource ButtonBackgroundColor}"
|
||||
StrokeThickness="1"
|
||||
Width="14"
|
||||
Height="14"
|
||||
SnapsToDevicePixels="True"/>
|
||||
<Ellipse x:Name="InnerCircle"
|
||||
<Ellipse Name="InnerCircle"
|
||||
Fill="{DynamicResource ToggleButtonOnColor}"
|
||||
Width="8"
|
||||
Height="8"
|
||||
@@ -682,7 +681,7 @@
|
||||
<Trigger Property="IsChecked" Value="false">
|
||||
<Trigger.ExitActions>
|
||||
<RemoveStoryboard BeginStoryboardName="WPFToggleSwitchLeft" />
|
||||
<BeginStoryboard x:Name="WPFToggleSwitchRight">
|
||||
<BeginStoryboard Name="WPFToggleSwitchRight">
|
||||
<Storyboard>
|
||||
<ThicknessAnimation Storyboard.TargetProperty="Margin"
|
||||
Storyboard.TargetName="WPFToggleSwitchButton"
|
||||
@@ -701,7 +700,7 @@
|
||||
<Trigger Property="IsChecked" Value="true">
|
||||
<Trigger.ExitActions>
|
||||
<RemoveStoryboard BeginStoryboardName="WPFToggleSwitchRight" />
|
||||
<BeginStoryboard x:Name="WPFToggleSwitchLeft">
|
||||
<BeginStoryboard Name="WPFToggleSwitchLeft">
|
||||
<Storyboard>
|
||||
<ThicknessAnimation Storyboard.TargetProperty="Margin"
|
||||
Storyboard.TargetName="WPFToggleSwitchButton"
|
||||
@@ -727,17 +726,17 @@
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type ToggleButton}">
|
||||
<Grid x:Name="toggleSwitch">
|
||||
<Grid Name="toggleSwitch">
|
||||
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="1" x:Name="Border" CornerRadius="8"
|
||||
<Border Grid.Column="1" Name="Border" CornerRadius="8"
|
||||
BorderThickness="1"
|
||||
Width="34" Height="17">
|
||||
<Ellipse x:Name="Ellipse" Fill="{DynamicResource MainForegroundColor}" Stretch="Uniform"
|
||||
<Ellipse Name="Ellipse" Fill="{DynamicResource MainForegroundColor}" Stretch="Uniform"
|
||||
Margin="2,2,2,1"
|
||||
HorizontalAlignment="Left" Width="10.8"
|
||||
RenderTransformOrigin="0.5, 0.5">
|
||||
@@ -884,7 +883,7 @@
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="5">
|
||||
<Grid>
|
||||
<ScrollViewer x:Name="PART_ContentHost" />
|
||||
<ScrollViewer Name="PART_ContentHost" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
@@ -916,7 +915,7 @@
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="5">
|
||||
<Grid>
|
||||
<ScrollViewer x:Name="PART_ContentHost" />
|
||||
<ScrollViewer Name="PART_ContentHost" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
|
||||
Reference in New Issue
Block a user