From 436773df73b948ba9f0a09e637466b64a2ac4ec6 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Thu, 20 Aug 2026 03:43:14 +0530 Subject: [PATCH] Fix legacy json import compatibility, UI sidebar refactor, and improved test coverage (#4911) * Fix backward compatibility for old-style JSON config imports * Refactor sidebar UI generation and add tests for Get-WinUtilVariables * Fix appnavigation config test following UI generation refactor * Restore global sync state in variables tests * fix(impex): migrate supported legacy selections Keep modern imports strict while allowing legacy backups to skip retired entries with clear logging and user feedback. Add fixtures covering partial and all-retired imports. * test: strengthen UI and global state coverage Exercise app category rendering through Initialize-WPFUI and restore global sync without leaking test state. * fix(impex): ignore legacy metadata fields Limit legacy selection migration to supported WPF key families so unrelated string metadata does not produce false retired-setting warnings. * docs: clarify legacy config imports Distinguish strict modern flat imports from partial legacy object migration, including single-setting export shape and historical groups. --------- Co-authored-by: Chris Titus --- docs/src/content/docs/guides/automation.mdx | 4 +- .../private/Initialize-WinUtilTabContent.ps1 | 1 - .../private/Update-WinUtilSelections.ps1 | 17 ++++- functions/public/Initialize-WPFUI.ps1 | 5 +- functions/public/Invoke-WPFImpex.ps1 | 60 +++++++++++++--- pester/configs.Tests.ps1 | 3 +- pester/fixtures/legacy-config.json | 22 ++++++ pester/lazy-tabs.Tests.ps1 | 34 +++++++-- pester/ui-state.Tests.ps1 | 71 +++++++++++++++++++ pester/variables.Tests.ps1 | 60 ++++++++++++++++ 10 files changed, 254 insertions(+), 23 deletions(-) create mode 100644 pester/fixtures/legacy-config.json create mode 100644 pester/variables.Tests.ps1 diff --git a/docs/src/content/docs/guides/automation.mdx b/docs/src/content/docs/guides/automation.mdx index 5612066d..f8b8b939 100644 --- a/docs/src/content/docs/guides/automation.mdx +++ b/docs/src/content/docs/guides/automation.mdx @@ -43,7 +43,9 @@ This is useful for: - Standardizing deployments for labs, workstations, or personal setups :::caution[Keep exported configurations current] -Exported configurations contain the WinUtil catalog keys that existed when the file was created. If a later WinUtil version removes or renames one of those keys, the import is rejected before any current selections are changed. PowerShell reports the stale entry as `Unknown selection key ''`. +Exported configurations contain the WinUtil catalog keys that existed when the file was created. Current exports use a flat JSON format: one string when a single setting is selected, or an array of strings when several settings are selected. If a later WinUtil version removes or renames any imported key, the entire import is rejected before current selections are changed. PowerShell reports the stale entry as `Unknown selection key ''`. + +Older WinUtil versions exported a JSON object with `Install` package metadata and grouped `WPFInstall`, `WPFTweaks`, `WPFToggle`, and `WPFFeature` selections. When importing one of these legacy files, WinUtil restores the keys that still exist and skips retired keys, recording them as a warning in the WinUtil log. If the file contains no supported selections, the import makes no changes. To recover, compare the reported key with the current files in the [WinUtil configuration catalog](https://github.com/ChrisTitusTech/winutil/tree/main/config). Remove or replace the stale key in your JSON file, or create and export a new configuration with the current WinUtil version, then run the import again. Re-export long-lived baselines after catalog changes so they remain compatible. ::: diff --git a/functions/private/Initialize-WinUtilTabContent.ps1 b/functions/private/Initialize-WinUtilTabContent.ps1 index 3f1881bc..db57efe4 100644 --- a/functions/private/Initialize-WinUtilTabContent.ps1 +++ b/functions/private/Initialize-WinUtilTabContent.ps1 @@ -14,7 +14,6 @@ function Initialize-WinUtilTabContent { switch ($TabName) { "Install" { - Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1 Initialize-WPFUI -targetGridName "appscategory" Initialize-WPFUI -targetGridName "appspanel" diff --git a/functions/private/Update-WinUtilSelections.ps1 b/functions/private/Update-WinUtilSelections.ps1 index aff293d4..933eaa30 100644 --- a/functions/private/Update-WinUtilSelections.ps1 +++ b/functions/private/Update-WinUtilSelections.ps1 @@ -3,7 +3,9 @@ function Update-WinUtilSelections { [Parameter(Mandatory)] [string[]]$flatJson, - [switch]$Replace + [switch]$Replace, + + [switch]$SkipUnknown ) $nextSelections = @{ @@ -25,6 +27,10 @@ function Update-WinUtilSelections { } if (-not $listName) { + if ($SkipUnknown) { + $cbkey + continue + } throw "Unsupported selection key '$cbkey'." } @@ -47,12 +53,21 @@ function Update-WinUtilSelections { } if (-not $isKnownSelection) { + if ($SkipUnknown) { + $cbkey + continue + } throw "Unknown selection key '$cbkey'." } $nextSelections[$listName].Add($cbkey) } + $validSelectionCount = ($nextSelections.Values | ForEach-Object { $_.Count } | Measure-Object -Sum).Sum + if ($SkipUnknown -and $validSelectionCount -eq 0) { + return + } + if ($Replace) { foreach ($listName in $nextSelections.Keys) { $sync[$listName] = $nextSelections[$listName] diff --git a/functions/public/Initialize-WPFUI.ps1 b/functions/public/Initialize-WPFUI.ps1 index 46d8b9e8..17bdad03 100644 --- a/functions/public/Initialize-WPFUI.ps1 +++ b/functions/public/Initialize-WPFUI.ps1 @@ -7,10 +7,7 @@ function Initialize-WPFUI { switch ($TargetGridName) { "appscategory"{ - # TODO - # Switch UI generation of the sidebar to this function - # $sync.ItemsControl = Initialize-InstallAppArea -TargetElement $TargetGridName - # ... + Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1 # Create and configure a popup for displaying selected apps $selectedAppsPopup = New-Object Windows.Controls.Primitives.Popup diff --git a/functions/public/Invoke-WPFImpex.ps1 b/functions/public/Invoke-WPFImpex.ps1 index 5748811a..cabf0ff4 100644 --- a/functions/public/Invoke-WPFImpex.ps1 +++ b/functions/public/Invoke-WPFImpex.ps1 @@ -73,15 +73,29 @@ function Invoke-WPFImpex { Write-Error "Failed to load the JSON file from the specified path or URL: $_" return } - if ($null -ne $jsonFile -and $jsonFile.PSObject.Properties['Install']) { + $isLegacyConfig = $jsonFile -is [System.Management.Automation.PSCustomObject] -and + $null -ne $jsonFile.PSObject.Properties["Install"] -and + $null -ne $jsonFile.PSObject.Properties["WPFInstall"] + if ($isLegacyConfig) { Write-WinUtilLog -Component "Impex" -Message "Detected legacy WinUtil config structure; flattening import object." - $flattenedJson = @() - foreach ($prop in $jsonFile.PSObject.Properties) { - if ($prop.Name -ne "Install" -and $null -ne $prop.Value) { - $flattenedJson += @($prop.Value) + # Legacy exports stored checkbox keys in WPFInstall and duplicated package + # source metadata in Install. Current package IDs come from the app catalog, + # so only the selection-key properties are restored. + $flattenedJson = @( + foreach ($property in $jsonFile.PSObject.Properties) { + if ($property.Name -notmatch '^WPF(?:Install|Tweaks|Toggle|Feature|Appx)') { + continue + } + + foreach ($selection in @($property.Value)) { + if ($selection -is [string] -and -not [string]::IsNullOrWhiteSpace($selection)) { + $selection + } + } } - } + ) } else { + # New style config: flat array of strings $flattenedJson = $jsonFile } @@ -92,9 +106,37 @@ function Invoke-WPFImpex { return } - # Build and validate every imported selection before replacing the current - # state. This keeps a malformed config from leaving partial selections behind. - Update-WinUtilSelections -flatJson $flattenedJson -Replace + # Modern configs stay strict. Legacy configs can reference entries that no + # longer exist, so restore supported selections and report the retired keys. + if ($isLegacyConfig) { + $skippedSelections = @(Update-WinUtilSelections -flatJson $flattenedJson -Replace -SkipUnknown) + + if ($skippedSelections.Count -gt 0) { + $skippedSummary = $skippedSelections -join ", " + Write-WinUtilLog -Component "Impex" -Level "WARN" -Message "Skipped unsupported legacy selections: $skippedSummary" + } + + if ($skippedSelections.Count -eq @($flattenedJson).Count) { + if ($sync.Form) { + Show-WinUtilMessage -Message "This legacy configuration contains no settings supported by this version of WinUtil. No changes have been made." -Title "Unsupported Legacy Configuration" -Icon "Warning" | Out-Null + } + return + } + + if ($skippedSelections.Count -gt 0) { + $skippedDisplay = @($skippedSelections | Select-Object -First 10) -join ", " + if ($skippedSelections.Count -gt 10) { + $skippedDisplay += "`n...and $($skippedSelections.Count - 10) more. See the WinUtil log for details." + } + if ($sync.Form) { + Show-WinUtilMessage -Message "Supported settings were imported. The following retired settings were skipped:`n`n$skippedDisplay" -Title "Legacy Configuration Partially Imported" -Icon "Warning" | Out-Null + } + } + } else { + # Build and validate every imported selection before replacing the current + # state. This keeps a malformed config from leaving partial selections behind. + Update-WinUtilSelections -flatJson $flattenedJson -Replace + } if ($sync.Form) { Reset-WPFCheckBoxes -doToggles $true diff --git a/pester/configs.Tests.ps1 b/pester/configs.Tests.ps1 index 49af330e..2425f544 100644 --- a/pester/configs.Tests.ps1 +++ b/pester/configs.Tests.ps1 @@ -264,8 +264,9 @@ 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 + $uiInitializerScript = Get-Content -Path (Join-Path $script:repoRoot "functions/public/Initialize-WPFUI.ps1") -Raw $targetGridMatch = [regex]::Match( - "$mainScript`n$tabInitializerScript", + "$mainScript`n$tabInitializerScript`n$uiInitializerScript", 'Invoke-WPFUIElements\s+-configVariable\s+\$sync\.configs\.appnavigation\s+-targetGridName\s+"([^"]+)"' ) diff --git a/pester/fixtures/legacy-config.json b/pester/fixtures/legacy-config.json new file mode 100644 index 00000000..ca911ecc --- /dev/null +++ b/pester/fixtures/legacy-config.json @@ -0,0 +1,22 @@ +{ + "Install": [ + { + "winget": "Git.Git", + "choco": "git" + }, + { + "winget": "Retired.App", + "choco": "retired-app" + } + ], + "WPFTweaks": [ + "WPFTweaksTelemetry" + ], + "WPFFeature": [ + "WPFFeatureSandbox" + ], + "WPFInstall": [ + "WPFInstallGit", + "WPFInstallRetired" + ] +} diff --git a/pester/lazy-tabs.Tests.ps1 b/pester/lazy-tabs.Tests.ps1 index 1c4db6c6..8a3e4129 100644 --- a/pester/lazy-tabs.Tests.ps1 +++ b/pester/lazy-tabs.Tests.ps1 @@ -8,12 +8,10 @@ BeforeAll { function Invoke-WPFUIElements { param($configVariable, [string]$targetGridName, [int]$columncount) } - function Initialize-WPFUI { - param([string]$TargetGridName) - } function Invoke-WinUtilISOCheckExistingWork { } function Reset-WPFCheckBoxes { param([bool]$doToggles) } + . (Join-Path $script:repoRoot "functions\public\Initialize-WPFUI.ps1") . (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1") } @@ -41,9 +39,7 @@ Describe "Initialize-WinUtilTabContent" { 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" } @@ -105,6 +101,32 @@ Describe "Initialize-WinUtilTabContent" { } } +Describe "Initialize-WPFUI" { + BeforeEach { + $script:sync = [Hashtable]::Synchronized(@{ + configs = @{ + appnavigation = [pscustomobject]@{} + } + }) + + Mock Invoke-WPFUIElements { throw "App category rendered" } + } + + AfterEach { + Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue + } + + It "renders app navigation through the app category target" { + { Initialize-WPFUI -TargetGridName "appscategory" } | Should -Throw "App category rendered" + + Should -Invoke -CommandName Invoke-WPFUIElements -Times 1 -Exactly -ParameterFilter { + $configVariable -eq $script:sync.configs.appnavigation -and + $targetGridName -eq "appscategory" -and + $columncount -eq 1 + } + } +} + 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 diff --git a/pester/ui-state.Tests.ps1 b/pester/ui-state.Tests.ps1 index b7a0e77c..d09f471c 100644 --- a/pester/ui-state.Tests.ps1 +++ b/pester/ui-state.Tests.ps1 @@ -93,6 +93,9 @@ namespace System.Windows.Controls function Write-WinUtilLog { param($Message, $Level, $Component) } + function Show-WinUtilMessage { + param($Message, $Title, $Button, $Icon) + } function script:New-WinUtilFakeCheckBox { param([bool]$IsChecked = $false) @@ -244,6 +247,8 @@ Describe "Invoke-WPFImpex import selection state" { Mock Reset-WPFCheckBoxes { } Mock Write-Error { } + Mock Write-WinUtilLog { } + Mock Show-WinUtilMessage { } } AfterEach { @@ -271,6 +276,72 @@ Describe "Invoke-WPFImpex import selection state" { Should -Invoke -CommandName Write-Error -Times 0 -Exactly } + It "imports supported legacy selections and reports retired entries" { + $configPath = Join-Path $script:repoRoot "pester\fixtures\legacy-config.json" + + Invoke-WPFImpex -type "import" -Config $configPath + + @($script:sync.selectedApps) | Should -Be @("WPFInstallGit") + @($script:sync.selectedTweaks) | Should -Be @("WPFTweaksTelemetry") + @($script:sync.selectedFeatures) | Should -Be @("WPFFeatureSandbox") + Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly -ParameterFilter { + $doToggles -eq $true + } + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Component -eq "Impex" -and + $Level -eq "WARN" -and + $Message -eq "Skipped unsupported legacy selections: WPFInstallRetired" + } + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Title -eq "Legacy Configuration Partially Imported" -and + $Message -like "*WPFInstallRetired*" + } + Should -Invoke -CommandName Write-Error -Times 0 -Exactly + } + + It "ignores unrelated string metadata in a legacy configuration" { + $configPath = Join-Path $TestDrive "legacy-config-with-metadata.json" + [pscustomobject]@{ + Install = @([pscustomobject]@{ winget = "Git.Git"; choco = "git" }) + WPFInstall = @("WPFInstallGit") + ExportVersion = "1.0" + } | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $configPath + + Invoke-WPFImpex -type "import" -Config $configPath + + @($script:sync.selectedApps) | Should -Be @("WPFInstallGit") + Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Component -eq "Impex" -and + $Message -eq "Detected legacy WinUtil config structure; flattening import object." + } + Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly + Should -Invoke -CommandName Write-Error -Times 0 -Exactly + } + + It "preserves selections when every legacy entry is retired" { + $script:sync.selectedApps.Add("WPFInstallExisting") + $configPath = Join-Path $TestDrive "retired-legacy-config.json" + [pscustomobject]@{ + Install = @([pscustomobject]@{ winget = "Retired.App"; choco = "retired-app" }) + WPFTweaks = @() + WPFFeature = @() + WPFInstall = @("WPFInstallRetired") + } | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $configPath + + Invoke-WPFImpex -type "import" -Config $configPath + + @($script:sync.selectedApps) | Should -Be @("WPFInstallExisting") + Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 0 -Exactly + Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter { + $Message -eq "Skipped unsupported legacy selections: WPFInstallRetired" + } + Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter { + $Title -eq "Unsupported Legacy Configuration" + } + Should -Invoke -CommandName Write-Error -Times 0 -Exactly + } + It "imports legacy selection groups without treating Install metadata as a selection" { $legacyConfigPath = Join-Path $TestDrive "legacy-config.json" [ordered]@{ diff --git a/pester/variables.Tests.ps1 b/pester/variables.Tests.ps1 new file mode 100644 index 00000000..80da5c99 --- /dev/null +++ b/pester/variables.Tests.ps1 @@ -0,0 +1,60 @@ +#=========================================================================== +# Tests - Get-WinUtilVariables +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:originalSyncVariable = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + if ($script:originalSyncVariable) { + $script:originalSyncValue = $script:originalSyncVariable.Value + } + $global:sync = [Hashtable]::Synchronized(@{}) + + # Setup some test variables + $global:sync["WPFTestString"] = "I am a string" + $global:sync["WPFTestObj1"] = [PSCustomObject]@{ Name = "Test1" } + $global:sync["WPFTestObj2"] = [PSCustomObject]@{ Name = "Test2" } + $global:sync["WPFTestButton"] = [System.Version]::new("1.0.0.0") + $global:sync["OtherVar"] = "Not a WPF variable" + + . (Join-Path $script:repoRoot "functions\private\Get-WinUtilVariables.ps1") +} + +AfterAll { + if ($script:originalSyncVariable) { + Set-Variable -Name sync -Value $script:originalSyncValue -Scope Global -Force + } else { + Remove-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue + } +} + +Describe "Get-WinUtilVariables" { + + It "returns all WPF-prefixed keys when no type is provided" { + $result = Get-WinUtilVariables + $result.Count | Should -Be 4 + $result | Should -Contain "WPFTestString" + $result | Should -Contain "WPFTestObj1" + $result | Should -Contain "WPFTestObj2" + $result | Should -Contain "WPFTestButton" + $result | Should -Not -Contain "OtherVar" + } + + It "returns only WPF keys matching the specified exact type" { + $result = Get-WinUtilVariables -Type "String" + $result.Count | Should -Be 1 + $result | Should -Contain "WPFTestString" + } + + It "returns multiple objects matching PSCustomObject" { + $result = Get-WinUtilVariables -Type "PSCustomObject" + $result.Count | Should -Be 2 + $result | Should -Contain "WPFTestObj1" + $result | Should -Contain "WPFTestObj2" + } + + It "returns an empty list when no matching type is found" { + $result = Get-WinUtilVariables -Type "Int32" + $result | Should -BeNullOrEmpty + } +}