From 71d72d51dfdabd589e33edf213aca9fd9d652981 Mon Sep 17 00:00:00 2001 From: AdhamHossamm Date: Sun, 17 May 2026 20:07:48 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20make=20BurntToast=20smarter=20?= =?UTF-8?q?=E2=80=94=20built-in=20throttling,=20auto=20app=20logo=20detect?= =?UTF-8?q?ion,=20and=20Claude=20Code=20ready?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toast notifications just got intelligent. Two features that make BurntToast aware of its context and respectful of your attention: 1. Notification Throttling (CooldownSeconds): Built-in spam prevention via per-identifier lock files in %TEMP%. Rapid-fire scripts, CI hooks, and automation loops no longer flood your screen — only the first toast within the cooldown window fires. Uses .NET I/O to bypass WhatIf scope so state persists during dry runs. 2. Auto App Logo Detection (AutoAppLogo + AppLogoMap): BurntToast now knows who called it. Walks the process tree to find the parent terminal or IDE, extracts its icon, and uses it as the toast logo. AppLogoMap lets you pin custom icons to specific callers — e.g., mapping 'node' to Claude's logo so Claude Code notifications look native. Born from real-world Claude Code hook integration where rapid subagent completions were spamming toasts and every notification looked the same regardless of which app triggered it. 18 new Pester v5 tests. Full suite: 98/98 passing, zero regressions. Co-Authored-By: Tawaar Technologies --- Tests/Get-BTCallerAppIcon.Tests.ps1 | 109 +++++++++++++++++ Tests/Throttle.Tests.ps1 | 135 ++++++++++++++++++++++ src/Private/Get-BTCallerAppIcon.ps1 | 88 ++++++++++++++ src/Public/New-BurntToastNotification.ps1 | 72 +++++++++++- src/Public/Submit-BTNotification.ps1 | 45 +++++++- 5 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 Tests/Get-BTCallerAppIcon.Tests.ps1 create mode 100644 Tests/Throttle.Tests.ps1 create mode 100644 src/Private/Get-BTCallerAppIcon.ps1 diff --git a/Tests/Get-BTCallerAppIcon.Tests.ps1 b/Tests/Get-BTCallerAppIcon.Tests.ps1 new file mode 100644 index 0000000..6d6c50b --- /dev/null +++ b/Tests/Get-BTCallerAppIcon.Tests.ps1 @@ -0,0 +1,109 @@ +BeforeAll { + if (Get-Module -Name 'BurntToast') { + Remove-Module -Name 'BurntToast' + } + + if ($ENV:BURNTTOAST_MODULE_ROOT) { + Import-Module $ENV:BURNTTOAST_MODULE_ROOT -Force + } else { + Import-Module "$PSScriptRoot/../src/BurntToast.psd1" -Force + } + + # Clean icon cache before tests so we get fresh extraction results. + $iconCacheDir = Join-Path $env:TEMP 'BurntToast-IconCache' + if (Test-Path $iconCacheDir) { Remove-Item $iconCacheDir -Recurse -Force } +} + +Describe 'Get-BTCallerAppIcon' { + # Private function — must use InModuleScope to access it from tests. + Context 'function exists' { + It 'is defined as a private function in the module' { + InModuleScope 'BurntToast' { + $cmd = Get-Command -Name 'Get-BTCallerAppIcon' -ErrorAction SilentlyContinue + $cmd | Should -Not -BeNullOrEmpty + } + } + } + + Context 'returns an icon path' { + It 'returns a string path when called' { + $result = InModuleScope 'BurntToast' { Get-BTCallerAppIcon } + $result | Should -BeOfType [string] + } + + It 'returns a path to a file that exists' { + $result = InModuleScope 'BurntToast' { Get-BTCallerAppIcon } + if ($result) { + Test-Path $result | Should -BeTrue + } + } + } + + Context 'override map' { + It 'uses override icon when process name matches AppLogoMap key' { + $ModuleRoot = (Get-Item (Get-Module 'BurntToast').Path).Directory.FullName + $fallbackIcon = Join-Path $ModuleRoot 'Images\BurntToast.png' + + # Map the current PowerShell process name so the override triggers. + $currentProc = (Get-Process -Id $PID).ProcessName + $result = InModuleScope 'BurntToast' -Parameters @{ procName = $currentProc; iconPath = $fallbackIcon } { + param($procName, $iconPath) + Get-BTCallerAppIcon -AppLogoMap @{ $procName = $iconPath } + } + $result | Should -Be $fallbackIcon + } + + It 'ignores override map when process name does not match' { + $result = InModuleScope 'BurntToast' { + Get-BTCallerAppIcon -AppLogoMap @{ 'nonexistent-process-xyz' = 'C:\fake\icon.ico' } + } + $result | Should -Not -Be 'C:\fake\icon.ico' + } + } + + Context 'icon caching' { + It 'caches extracted icons in temp directory' { + $result = InModuleScope 'BurntToast' { Get-BTCallerAppIcon } + if ($result) { + $result | Should -Match ([regex]::Escape($env:TEMP)) + } + } + } +} + +Describe 'New-BurntToastNotification AutoAppLogo' { + Context 'AutoAppLogo switch exists' { + It 'accepts -AutoAppLogo without error' { + { New-BurntToastNotification -Text 'Auto Logo Test' -AutoAppLogo -WhatIf } | Should -Not -Throw + } + } + + Context 'AutoAppLogo overrides default logo' { + It 'uses detected app icon instead of default BurntToast logo' { + Start-Transcript tmp-autologo.log + try { + New-BurntToastNotification -Text 'Auto Logo' -AutoAppLogo -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-autologo.log).Where({ $_ -match 'What if:' }) + Remove-Item tmp-autologo.log + } + $ModuleRoot = (Get-Item (Get-Module 'BurntToast').Path).Directory.FullName + $defaultLogo = Join-Path $ModuleRoot 'Images\BurntToast.png' + $Log | Should -Not -Match ([regex]::Escape($defaultLogo)) + } + } + + Context 'AutoAppLogo with AppLogoMap' { + It 'accepts -AppLogoMap hashtable parameter' { + $map = @{ 'pwsh' = 'C:\test\icon.ico'; 'powershell' = 'C:\test\icon.ico' } + { New-BurntToastNotification -Text 'Map Test' -AutoAppLogo -AppLogoMap $map -WhatIf } | Should -Not -Throw + } + } + + Context 'AutoAppLogo and AppLogo are mutually exclusive' { + It 'throws when both -AutoAppLogo and -AppLogo are specified' { + { New-BurntToastNotification -Text 'Conflict' -AutoAppLogo -AppLogo 'C:\test.ico' -WhatIf } | Should -Throw + } + } +} diff --git a/Tests/Throttle.Tests.ps1 b/Tests/Throttle.Tests.ps1 new file mode 100644 index 0000000..74b6210 --- /dev/null +++ b/Tests/Throttle.Tests.ps1 @@ -0,0 +1,135 @@ +BeforeAll { + if (Get-Module -Name 'BurntToast') { + Remove-Module -Name 'BurntToast' + } + + if ($ENV:BURNTTOAST_MODULE_ROOT) { + Import-Module $ENV:BURNTTOAST_MODULE_ROOT -Force + } else { + Import-Module "$PSScriptRoot/../src/BurntToast.psd1" -Force + } +} + +Describe 'Submit-BTNotification Throttle' { + BeforeEach { + $lockDir = Join-Path $env:TEMP 'BurntToast-Throttle' + if (Test-Path $lockDir) { Remove-Item $lockDir -Recurse -Force } + } + + AfterAll { + $lockDir = Join-Path $env:TEMP 'BurntToast-Throttle' + if (Test-Path $lockDir) { Remove-Item $lockDir -Recurse -Force } + } + + Context 'CooldownSeconds parameter exists' { + It 'accepts -CooldownSeconds without error' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + { Submit-BTNotification -Content $mockContent -UniqueIdentifier 'throttle-test' -CooldownSeconds 10 -WhatIf } | Should -Not -Throw + } + } + + Context 'first notification always fires' { + It 'shows the toast on first call even with cooldown set' { + Start-Transcript tmp-throttle.log + try { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'first-call' -CooldownSeconds 30 -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-throttle.log).Where({ $_ -match 'What if:' }) + Remove-Item tmp-throttle.log + } + $Log | Should -Not -BeNullOrEmpty + } + } + + Context 'second notification within cooldown is suppressed' { + It 'does not show toast when called again within cooldown window' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + + # First call — should fire + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'spam-test' -CooldownSeconds 60 -WhatIf + + # Second call within cooldown — should be suppressed + Start-Transcript tmp-throttle2.log + try { + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'spam-test' -CooldownSeconds 60 -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-throttle2.log).Where({ $_ -match 'What if:' }) + Remove-Item tmp-throttle2.log + } + $Log | Should -BeNullOrEmpty + } + } + + Context 'different identifiers are independent' { + It 'allows notifications with different UniqueIdentifiers regardless of cooldown' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'id-A' -CooldownSeconds 60 -WhatIf + + Start-Transcript tmp-throttle3.log + try { + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'id-B' -CooldownSeconds 60 -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-throttle3.log).Where({ $_ -match 'What if:' }) + Remove-Item tmp-throttle3.log + } + $Log | Should -Not -BeNullOrEmpty + } + } + + Context 'no cooldown means no throttling' { + It 'fires every time when CooldownSeconds is not specified' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'no-cd' -WhatIf + + Start-Transcript tmp-throttle4.log + try { + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'no-cd' -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-throttle4.log).Where({ $_ -match 'What if:' }) + Remove-Item tmp-throttle4.log + } + $Log | Should -Not -BeNullOrEmpty + } + } + + Context 'cooldown requires UniqueIdentifier' { + It 'writes a warning when CooldownSeconds used without UniqueIdentifier' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + $output = Submit-BTNotification -Content $mockContent -CooldownSeconds 10 -WhatIf 3>&1 + $warnings = $output | Where-Object { $_ -is [System.Management.Automation.WarningRecord] } + $warnings.Message | Should -Match 'UniqueIdentifier' + } + } +} + +Describe 'New-BurntToastNotification Throttle Passthrough' { + BeforeEach { + $lockDir = Join-Path $env:TEMP 'BurntToast-Throttle' + if (Test-Path $lockDir) { Remove-Item $lockDir -Recurse -Force } + } + + It 'accepts -CooldownSeconds parameter' { + { New-BurntToastNotification -Text 'Throttle Test' -UniqueIdentifier 'passthrough' -CooldownSeconds 10 -WhatIf } | Should -Not -Throw + } + + It 'suppresses second call within cooldown' { + New-BurntToastNotification -Text 'First' -UniqueIdentifier 'pt-spam' -CooldownSeconds 60 -WhatIf + + Start-Transcript tmp-throttle-pt.log + try { + New-BurntToastNotification -Text 'Second' -UniqueIdentifier 'pt-spam' -CooldownSeconds 60 -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-throttle-pt.log).Where({ $_ -match 'What if:' }) + Remove-Item tmp-throttle-pt.log + } + $Log | Should -BeNullOrEmpty + } +} diff --git a/src/Private/Get-BTCallerAppIcon.ps1 b/src/Private/Get-BTCallerAppIcon.ps1 new file mode 100644 index 0000000..d167d8c --- /dev/null +++ b/src/Private/Get-BTCallerAppIcon.ps1 @@ -0,0 +1,88 @@ +function Get-BTCallerAppIcon { + <# + .SYNOPSIS + Detects the calling application (terminal, IDE, etc.) and extracts its icon. + + .DESCRIPTION + Walks up the process tree from the current PowerShell session to find the first + ancestor with a visible window — that's typically the terminal or IDE that launched + the script. Extracts the app's icon from its executable and caches it in + %TEMP%\BurntToast-IconCache so repeated calls don't re-extract. + + Supports an optional override map so specific process names can use custom icons + (e.g., mapping 'node' to a Claude logo when BurntToast is called from Claude Code). + + .PARAMETER AppLogoMap + Hashtable mapping process names (without .exe) to icon file paths. + If the detected parent process matches a key, that icon is used instead of extracting. + + .OUTPUTS + System.String — path to the icon file, or $null if detection fails. + #> + + param ( + [hashtable] $AppLogoMap + ) + + $cacheDir = Join-Path $env:TEMP 'BurntToast-IconCache' + + # Walk up the process tree to find the first ancestor with a visible window. + # That's usually the terminal, IDE, or app shell that the user is looking at. + try { + $proc = Get-Process -Id $PID -ErrorAction Stop + + while ($proc) { + # Check for override map match first — before we even care about the window handle. + if ($AppLogoMap -and $AppLogoMap.ContainsKey($proc.ProcessName)) { + $overridePath = $AppLogoMap[$proc.ProcessName] + if (Test-Path $overridePath) { return $overridePath } + } + + # Found a windowed process — this is our target. + if ($proc.MainWindowHandle -ne [IntPtr]::Zero) { break } + + # Move up to parent process. + $parentId = (Get-CimInstance Win32_Process -Filter "ProcessId=$($proc.Id)" -ErrorAction Stop).ParentProcessId + if (-not $parentId -or $parentId -eq 0) { return $null } + $proc = Get-Process -Id $parentId -ErrorAction SilentlyContinue + } + + if (-not $proc -or $proc.MainWindowHandle -eq [IntPtr]::Zero) { return $null } + + # Check override map for the windowed ancestor too. + if ($AppLogoMap -and $AppLogoMap.ContainsKey($proc.ProcessName)) { + $overridePath = $AppLogoMap[$proc.ProcessName] + if (Test-Path $overridePath) { return $overridePath } + } + + # Extract the icon from the process's executable and cache it as a .png. + # We cache by process name so different apps get their own icon file. + if (-not [System.IO.Directory]::Exists($cacheDir)) { + [System.IO.Directory]::CreateDirectory($cacheDir) | Out-Null + } + + $cachedIcon = Join-Path $cacheDir "$($proc.ProcessName).png" + + if ([System.IO.File]::Exists($cachedIcon)) { return $cachedIcon } + + # Use System.Drawing to extract the icon from the .exe and save as PNG. + $exePath = $proc.Path + if (-not $exePath) { return $null } + + Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue + $icon = [System.Drawing.Icon]::ExtractAssociatedIcon($exePath) + if ($icon) { + $bitmap = $icon.ToBitmap() + $bitmap.Save($cachedIcon, [System.Drawing.Imaging.ImageFormat]::Png) + $bitmap.Dispose() + $icon.Dispose() + return $cachedIcon + } + } catch { + # If anything goes wrong during detection, return $null and let the caller + # fall back to the default BurntToast logo. No crash, no noise. + return $null + } + + return $null +} diff --git a/src/Public/New-BurntToastNotification.ps1 b/src/Public/New-BurntToastNotification.ps1 index 0a1b628..699fd74 100644 --- a/src/Public/New-BurntToastNotification.ps1 +++ b/src/Public/New-BurntToastNotification.ps1 @@ -68,6 +68,22 @@ .PARAMETER Urgent If set, designates the toast as an "Important Notification" (scenario 'urgent'), allowing it to break through Focus Assist. + .PARAMETER CooldownSeconds + Minimum seconds between repeated toasts sharing the same UniqueIdentifier. + Prevents notification spam from rapid-fire scripts, hooks, or automation loops. + Passed through to Submit-BTNotification where the actual throttle logic runs. + + .PARAMETER AutoAppLogo + Automatically detects the calling application (terminal, IDE, etc.) by walking the + process tree, extracts its icon, and uses it as the toast logo. Cannot be combined + with -AppLogo since they'd conflict over which image to show. + + .PARAMETER AppLogoMap + Hashtable mapping process names (without .exe) to custom icon file paths. + Used with -AutoAppLogo to override the auto-detected icon for specific apps. + Example: @{ 'node' = 'C:\icons\claude.ico' } uses the Claude icon when the + calling process is node.exe (as it is when running Claude Code). + .INPUTS None. You cannot pipe input to this function. @@ -112,6 +128,16 @@ [String] $AppLogo, + # Auto-detect the calling app (terminal, IDE) and use its icon as the toast logo. + # Walks the process tree to find the first ancestor with a visible window, + # extracts its .exe icon, and caches it. Cannot be combined with -AppLogo. + [Switch] $AutoAppLogo, + + # Override map for AutoAppLogo: keys are process names (without .exe), + # values are paths to custom icon files. Lets you pin specific icons to + # specific apps — e.g., @{ 'node' = 'C:\icons\claude.ico' } for Claude Code. + [hashtable] $AppLogoMap, + [String] $HeroImage, [String] $Attribution, @@ -192,9 +218,18 @@ [string] $EventDataVariable, - [switch] $Urgent + [switch] $Urgent, + + # Throttle: minimum seconds between repeated toasts sharing the same UniqueIdentifier. + # Passed through to Submit-BTNotification where the actual suppression happens. + [int] $CooldownSeconds ) + # AutoAppLogo and AppLogo both control the same image slot — can't use both. + if ($AutoAppLogo -and $AppLogo) { + throw 'Cannot use -AutoAppLogo and -AppLogo together. Choose one.' + } + $ChildObjects = @() foreach ($Txt in $Text) { @@ -207,7 +242,16 @@ } } - if ($AppLogo) { + # Resolve which logo to use: explicit path, auto-detected from caller app, or default. + if ($AutoAppLogo) { + $detectedIcon = Get-BTCallerAppIcon -AppLogoMap $AppLogoMap + if ($detectedIcon) { + $AppLogoImage = New-BTImage -Source $detectedIcon -AppLogoOverride -Crop Circle -WhatIf:$false + } else { + # Detection failed — fall back to the default BurntToast logo silently. + $AppLogoImage = New-BTImage -AppLogoOverride -Crop Circle -WhatIf:$false + } + } elseif ($AppLogo) { $AppLogoImage = New-BTImage -Source $AppLogo -AppLogoOverride -Crop Circle -WhatIf:$false } else { $AppLogoImage = New-BTImage -AppLogoOverride -Crop Circle -WhatIf:$false @@ -310,6 +354,30 @@ $ToastSplat.Add('Urgent', $true) } + # Throttle check at the wrapper level too — Submit-BTNotification has the same check, + # but this wrapper's own ShouldProcess runs first and would print WhatIf output before + # Submit-BTNotification even gets called. By checking here, we bail out early and avoid + # misleading WhatIf messages for throttled notifications. + if ($CooldownSeconds -and $UniqueIdentifier) { + $throttleDir = Join-Path $env:TEMP 'BurntToast-Throttle' + if (-not [System.IO.Directory]::Exists($throttleDir)) { + [System.IO.Directory]::CreateDirectory($throttleDir) | Out-Null + } + $throttleFile = Join-Path $throttleDir "$UniqueIdentifier.lock" + if ([System.IO.File]::Exists($throttleFile)) { + $lastFired = [System.IO.File]::GetLastWriteTime($throttleFile) + if (([datetime]::Now - $lastFired).TotalSeconds -lt $CooldownSeconds) { + return + } + } + [System.IO.File]::WriteAllText($throttleFile, (Get-Date -Format o)) + } + + # Still forward CooldownSeconds for callers using Submit-BTNotification directly. + if ($CooldownSeconds) { + $ToastSplat.Add('CooldownSeconds', $CooldownSeconds) + } + if ($PSCmdlet.ShouldProcess( "submitting: $($Content.GetContent())" )) { Submit-BTNotification @ToastSplat } diff --git a/src/Public/Submit-BTNotification.ps1 b/src/Public/Submit-BTNotification.ps1 index b47d616..5d8e409 100644 --- a/src/Public/Submit-BTNotification.ps1 +++ b/src/Public/Submit-BTNotification.ps1 @@ -54,6 +54,12 @@ .PARAMETER Urgent If set, designates the toast as an "Important Notification" (scenario 'urgent') which can break through Focus Assist, ensuring the notification is delivered even when user focus mode is enabled. + .PARAMETER CooldownSeconds + Minimum number of seconds between repeated toasts that share the same UniqueIdentifier. + If a toast with the same identifier was shown within this window, the new one is silently suppressed. + Requires UniqueIdentifier to be set — without it, there is nothing to key the cooldown on. + Throttle state is stored as lock files in %TEMP%\BurntToast-Throttle. + .INPUTS None. You cannot pipe input to this function. @@ -82,9 +88,46 @@ [scriptblock] $DismissedAction, [scriptblock] $FailedAction, [switch] $ReturnEventData, - [string] $EventDataVariable = 'ToastEvent' + [string] $EventDataVariable = 'ToastEvent', + + # Throttle: suppress duplicate toasts within N seconds. + # Useful for hooks or automation that fire rapidly (e.g., CI agents, subprocesses). + # Requires UniqueIdentifier so each notification type gets its own cooldown window. + [int] $CooldownSeconds ) + # Throttle guard: CooldownSeconds without UniqueIdentifier has nothing to key on, + # so we warn the user rather than silently ignoring it. + if ($CooldownSeconds -gt 0 -and -not $UniqueIdentifier) { + Write-Warning '-CooldownSeconds requires -UniqueIdentifier to track throttle state.' + } + + # Throttle logic: uses a per-identifier lock file in %TEMP%\BurntToast-Throttle. + # We compare the lock file's LastWriteTime against the cooldown window. + # If the last toast was shown within the window, we bail out early — no toast, no noise. + # Note: we use .NET I/O here instead of Set-Content/New-Item because those cmdlets + # respect -WhatIf from the calling scope, but throttle state is internal bookkeeping + # that must persist even during dry runs — otherwise the second call can't see the first. + if ($CooldownSeconds -gt 0 -and $UniqueIdentifier) { + $throttleDir = Join-Path $env:TEMP 'BurntToast-Throttle' + if (-not [System.IO.Directory]::Exists($throttleDir)) { + [System.IO.Directory]::CreateDirectory($throttleDir) | Out-Null + } + + $throttleFile = Join-Path $throttleDir "$UniqueIdentifier.lock" + + if ([System.IO.File]::Exists($throttleFile)) { + $lastFired = [System.IO.File]::GetLastWriteTime($throttleFile) + if (([datetime]::Now - $lastFired).TotalSeconds -lt $CooldownSeconds) { + # Still within cooldown — skip this notification entirely. + return + } + } + + # Touch the lock file so subsequent calls know when this one fired. + [System.IO.File]::WriteAllText($throttleFile, (Get-Date -Format o)) + } + if (-not $IsWindows) { $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] } From 9d772aac973ac2be9888b6683a0e714b6dfbdc08 Mon Sep 17 00:00:00 2001 From: AdhamHossamm Date: Sun, 17 May 2026 20:10:49 +0300 Subject: [PATCH 2/2] test: add 15 advanced edge-case tests for throttle and auto logo Stress tests covering: - 20-call rapid-fire loop (only 1 toast fires) - Cooldown expiry via backdated lock file - Zero and negative CooldownSeconds - Special characters in UniqueIdentifier - Lock file integrity and ISO 8601 timestamp validation - Concurrent identifier isolation (A+B throttled, C fires) - Nonexistent icon path graceful fallback - Icon cache reuse and PNG magic byte validation - Empty and multi-entry AppLogoMap - XML well-formedness with auto-detected icon - Combined throttle + AutoAppLogo interaction Full suite: 113/113 passing. Co-Authored-By: Tawaar Technologies --- Tests/Advanced-Throttle.Tests.ps1 | 279 ++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 Tests/Advanced-Throttle.Tests.ps1 diff --git a/Tests/Advanced-Throttle.Tests.ps1 b/Tests/Advanced-Throttle.Tests.ps1 new file mode 100644 index 0000000..3bad064 --- /dev/null +++ b/Tests/Advanced-Throttle.Tests.ps1 @@ -0,0 +1,279 @@ +BeforeAll { + if (Get-Module -Name 'BurntToast') { + Remove-Module -Name 'BurntToast' + } + + if ($ENV:BURNTTOAST_MODULE_ROOT) { + Import-Module $ENV:BURNTTOAST_MODULE_ROOT -Force + } else { + Import-Module "$PSScriptRoot/../src/BurntToast.psd1" -Force + } +} + +Describe 'Throttle: Advanced Edge Cases' { + BeforeEach { + # Fresh throttle state for every test — no leakage between tests. + $lockDir = Join-Path $env:TEMP 'BurntToast-Throttle' + if (Test-Path $lockDir) { Remove-Item $lockDir -Recurse -Force } + } + + AfterAll { + $lockDir = Join-Path $env:TEMP 'BurntToast-Throttle' + if (Test-Path $lockDir) { Remove-Item $lockDir -Recurse -Force } + } + + Context 'rapid-fire stress test' { + It 'only fires once when called 20 times in a tight loop with 60s cooldown' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + $firedCount = 0 + + for ($i = 0; $i -lt 20; $i++) { + Start-Transcript "tmp-stress-$i.log" -Force + try { + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'stress-test' -CooldownSeconds 60 -WhatIf + } finally { + Stop-Transcript + $log = (Get-Content "tmp-stress-$i.log").Where({ $_ -match 'What if:.*submitting:' }) + Remove-Item "tmp-stress-$i.log" + if ($log) { $firedCount++ } + } + } + + $firedCount | Should -Be 1 + } + } + + Context 'cooldown expiry' { + It 'fires again after cooldown expires (simulated by backdating lock file)' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + + # First call — fires normally. + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'expiry-test' -CooldownSeconds 5 -WhatIf + + # Backdate the lock file to simulate cooldown expiry. + $lockFile = Join-Path $env:TEMP 'BurntToast-Throttle\expiry-test.lock' + [System.IO.File]::SetLastWriteTime($lockFile, [datetime]::Now.AddSeconds(-10)) + + # Second call — should fire because cooldown expired. + Start-Transcript tmp-expiry.log -Force + try { + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'expiry-test' -CooldownSeconds 5 -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-expiry.log).Where({ $_ -match 'What if:.*submitting:' }) + Remove-Item tmp-expiry.log + } + $Log | Should -Not -BeNullOrEmpty + } + } + + Context 'zero cooldown behaves like no cooldown' { + It 'fires every time when CooldownSeconds is 0' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + $firedCount = 0 + + for ($i = 0; $i -lt 5; $i++) { + Start-Transcript "tmp-zero-$i.log" -Force + try { + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'zero-cd' -CooldownSeconds 0 -WhatIf + } finally { + Stop-Transcript + $log = (Get-Content "tmp-zero-$i.log").Where({ $_ -match 'What if:.*submitting:' }) + Remove-Item "tmp-zero-$i.log" + if ($log) { $firedCount++ } + } + } + + $firedCount | Should -Be 5 + } + } + + Context 'negative cooldown is treated as no throttle' { + It 'fires every time when CooldownSeconds is negative' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'neg-cd' -CooldownSeconds -5 -WhatIf + + Start-Transcript tmp-neg.log -Force + try { + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'neg-cd' -CooldownSeconds -5 -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-neg.log).Where({ $_ -match 'What if:.*submitting:' }) + Remove-Item tmp-neg.log + } + $Log | Should -Not -BeNullOrEmpty + } + } + + Context 'special characters in UniqueIdentifier' { + It 'handles identifiers with spaces and dashes' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + { Submit-BTNotification -Content $mockContent -UniqueIdentifier 'my toast-id 2' -CooldownSeconds 10 -WhatIf } | Should -Not -Throw + } + } + + Context 'lock file integrity' { + It 'creates lock file in correct directory with correct name' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'lockcheck' -CooldownSeconds 30 -WhatIf + + $lockFile = Join-Path $env:TEMP 'BurntToast-Throttle\lockcheck.lock' + Test-Path $lockFile | Should -BeTrue + } + + It 'lock file contains a valid ISO 8601 timestamp' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'timestamp-check' -CooldownSeconds 30 -WhatIf + + $lockFile = Join-Path $env:TEMP 'BurntToast-Throttle\timestamp-check.lock' + $content = [System.IO.File]::ReadAllText($lockFile) + { [datetime]::Parse($content) } | Should -Not -Throw + } + } + + Context 'concurrent identifier isolation' { + It 'throttling one identifier does not affect another with different cooldowns' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + + # Fire A with 60s cooldown — locks it. + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'iso-A' -CooldownSeconds 60 -WhatIf + # Fire B with 60s cooldown — independent lock. + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'iso-B' -CooldownSeconds 60 -WhatIf + + # A should be suppressed, B should be suppressed, C should fire. + Start-Transcript tmp-iso.log -Force + try { + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'iso-A' -CooldownSeconds 60 -WhatIf + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'iso-B' -CooldownSeconds 60 -WhatIf + Submit-BTNotification -Content $mockContent -UniqueIdentifier 'iso-C' -CooldownSeconds 60 -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-iso.log).Where({ $_ -match 'What if:.*submitting:' }) + Remove-Item tmp-iso.log + } + + # Only iso-C should have fired. + $Log.Count | Should -Be 1 + $Log[0] | Should -Match 'iso-C' + } + } +} + +Describe 'AutoAppLogo: Advanced Edge Cases' { + Context 'AppLogoMap with nonexistent icon path' { + It 'falls back gracefully when mapped icon file does not exist' { + # Map current process to a file that does not exist. + $currentProc = (Get-Process -Id $PID).ProcessName + $result = InModuleScope 'BurntToast' -Parameters @{ procName = $currentProc } { + param($procName) + Get-BTCallerAppIcon -AppLogoMap @{ $procName = 'C:\nonexistent\fake.ico' } + } + # Should NOT return the fake path — should fall through to extraction. + $result | Should -Not -Be 'C:\nonexistent\fake.ico' + } + } + + Context 'icon cache reuse' { + It 'returns the same cached path on repeated calls' { + $first = InModuleScope 'BurntToast' { Get-BTCallerAppIcon } + $second = InModuleScope 'BurntToast' { Get-BTCallerAppIcon } + $first | Should -Be $second + } + + It 'cached icon file is a valid PNG' { + $iconPath = InModuleScope 'BurntToast' { Get-BTCallerAppIcon } + if ($iconPath -and (Test-Path $iconPath)) { + # PNG magic bytes: 137 80 78 71 (0x89 0x50 0x4E 0x47) + $bytes = [System.IO.File]::ReadAllBytes($iconPath) + $bytes[0] | Should -Be 0x89 + $bytes[1] | Should -Be 0x50 + $bytes[2] | Should -Be 0x4E + $bytes[3] | Should -Be 0x47 + } + } + } + + Context 'empty AppLogoMap' { + It 'ignores empty hashtable and proceeds with auto-detection' { + $result = InModuleScope 'BurntToast' { Get-BTCallerAppIcon -AppLogoMap @{} } + $result | Should -Not -BeNullOrEmpty + } + } + + Context 'multiple AppLogoMap entries' { + It 'only matches the correct process name' { + $ModuleRoot = (Get-Item (Get-Module 'BurntToast').Path).Directory.FullName + $targetIcon = Join-Path $ModuleRoot 'Images\BurntToast.png' + $currentProc = (Get-Process -Id $PID).ProcessName + + $map = @{ + 'chrome' = 'C:\icons\chrome.ico' + 'firefox' = 'C:\icons\firefox.ico' + $currentProc = $targetIcon + 'nonexistent' = 'C:\icons\nope.ico' + } + $result = InModuleScope 'BurntToast' -Parameters @{ map = $map } { + param($map) + Get-BTCallerAppIcon -AppLogoMap $map + } + $result | Should -Be $targetIcon + } + } + + Context 'AutoAppLogo produces valid toast XML' { + It 'generates well-formed XML with detected icon path' { + Start-Transcript tmp-xml.log -Force + try { + New-BurntToastNotification -Text 'XML Validation' -AutoAppLogo -WhatIf + } finally { + Stop-Transcript + $Log = (Get-Content tmp-xml.log).Where({ $_ -match 'What if:' }) + Remove-Item tmp-xml.log + } + + # Extract the XML from the WhatIf output and validate it parses. + $xmlMatch = [regex]::Match($Log, '<\?xml.*') + $xmlMatch.Success | Should -BeTrue + + $xml = [xml]$xmlMatch.Value + $xml | Should -Not -BeNullOrEmpty + + # The image source should not be the default BurntToast.png. + $imgSrc = $xml.toast.visual.binding.image.src + $imgSrc | Should -Not -Match 'BurntToast\.png' + } + } + + Context 'combined throttle + AutoAppLogo' { + BeforeEach { + $lockDir = Join-Path $env:TEMP 'BurntToast-Throttle' + if (Test-Path $lockDir) { Remove-Item $lockDir -Recurse -Force } + } + + It 'throttles AND uses auto logo when both are set' { + # First call — fires with auto logo. + Start-Transcript tmp-combo1.log -Force + try { + New-BurntToastNotification -Text 'Combo' -AutoAppLogo -UniqueIdentifier 'combo' -CooldownSeconds 60 -WhatIf + } finally { + Stop-Transcript + $Log1 = (Get-Content tmp-combo1.log).Where({ $_ -match 'What if:' }) + Remove-Item tmp-combo1.log + } + $Log1 | Should -Not -BeNullOrEmpty + $Log1 | Should -Not -Match 'BurntToast\.png' + + # Second call — should be throttled (no output). + Start-Transcript tmp-combo2.log -Force + try { + New-BurntToastNotification -Text 'Combo Again' -AutoAppLogo -UniqueIdentifier 'combo' -CooldownSeconds 60 -WhatIf + } finally { + Stop-Transcript + $Log2 = (Get-Content tmp-combo2.log).Where({ $_ -match 'What if:' }) + Remove-Item tmp-combo2.log + } + $Log2 | Should -BeNullOrEmpty + } + } +}