mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-08-25 08:56:39 +00:00
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 <contact@christitus.com>
This commit is contained in:
@@ -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 '<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 '<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.
|
||||
:::
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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+"([^"]+)"'
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Install": [
|
||||
{
|
||||
"winget": "Git.Git",
|
||||
"choco": "git"
|
||||
},
|
||||
{
|
||||
"winget": "Retired.App",
|
||||
"choco": "retired-app"
|
||||
}
|
||||
],
|
||||
"WPFTweaks": [
|
||||
"WPFTweaksTelemetry"
|
||||
],
|
||||
"WPFFeature": [
|
||||
"WPFFeatureSandbox"
|
||||
],
|
||||
"WPFInstall": [
|
||||
"WPFInstallGit",
|
||||
"WPFInstallRetired"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]@{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user