diff --git a/pester/install-rendering.Tests.ps1 b/pester/install-rendering.Tests.ps1 index 0b4a5430..a264e732 100644 --- a/pester/install-rendering.Tests.ps1 +++ b/pester/install-rendering.Tests.ps1 @@ -33,6 +33,90 @@ Describe "Install app rendering startup contract" { $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