From 0790b4e1f87a9cafb5948a7632c37e96d641a0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Mon, 10 Aug 2026 21:37:23 +0200 Subject: [PATCH 1/2] Hint at unescaped wildcards on Should-Throw -ExceptionMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Should-Throw filters -ExceptionMessage with -like, so [ ] * ? are wildcards. When the actual message is identical to the expected one treated literally, the match fails only because of unescaped wildcard characters, and the two messages look the same in the failure. Detect that case and add a hint, same as the v5 Should -Throw does since #2956. To carry the hint I added an optional Hint key to the ShouldAssertion.Fail data. When present it overrides the default Get-AssertionGotcha hint and is appended with the standard "Hint: " format the other v6 assertions use, so no new formatting path is introduced. Fixes #2968 Fixes #2966 🤖 --- .../assert/Common/New-ShouldAssertion.ps1 | 16 +++++++++++----- .../assert/Exception/Should-Throw.ps1 | 17 +++++++++++++++-- .../Common/New-ShouldAssertion.Tests.ps1 | 19 +++++++++++++++++++ .../assert/Exception/Should-Throw.Tests.ps1 | 14 ++++++++++++++ 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/functions/assert/Common/New-ShouldAssertion.ps1 b/src/functions/assert/Common/New-ShouldAssertion.ps1 index 928b7c3cd..d4386bbbe 100644 --- a/src/functions/assert/Common/New-ShouldAssertion.ps1 +++ b/src/functions/assert/Common/New-ShouldAssertion.ps1 @@ -74,18 +74,23 @@ class ShouldAssertion { $actual = $this.Collected['Actual'] } $because = [string] $Data['Because'] + # An explicit Hint overrides the default Get-AssertionGotcha hint, for assertions that have a + # more specific thing to say about their own failure (e.g. Should-Throw explaining that the + # -ExceptionMessage filter failed only because of unescaped wildcard characters). Like the + # other reserved keys it is not turned into a token. + $hintOverride = [string] $Data['Hint'] - # Everything except the three special keys becomes a token in the message. + # Everything except the reserved keys becomes a token in the message. $extra = @{} foreach ($key in $Data.Keys) { - if ($key -ne 'Expected' -and $key -ne 'Actual' -and $key -ne 'Because') { + if ($key -ne 'Expected' -and $key -ne 'Actual' -and $key -ne 'Because' -and $key -ne 'Hint') { $extra[$key] = $Data[$key] } } $formattedMessage = Get-AssertionMessage -Expected $expected -Actual $actual -Because $because -Data $extra -DefaultMessage $Message -Pretty:$Pretty - $hint = $this.Hint() + $hint = if (-not [string]::IsNullOrEmpty($hintOverride)) { $hintOverride } else { $this.Hint() } if ($hint) { $formattedMessage = "$formattedMessage`n`nHint: $hint" } Invoke-AssertionFailed -Message $formattedMessage -CallerCmdlet $this.Caller @@ -133,8 +138,9 @@ function New-ShouldAssertion { or `CollectionItems`) selects both unrolling and the wording of the input hint. - `Fail(message [, data])` reports a failure. `message` may contain ``, ``, ``, ``, `` and any `` present in - `data`. `data` is a hashtable whose `Expected`, `Actual` and `Because` entries are - treated specially; all other entries become message tokens. Whether this throws + `data`. `data` is a hashtable whose `Expected`, `Actual`, `Because` and `Hint` entries + are treated specially; all other entries become message tokens. `Hint` overrides the + default input hint with your own text, appended as `Hint: `. Whether this throws immediately or records the failure and continues (a soft assertion) is decided by the caller's `-ErrorAction` or the `Should.ErrorAction` configuration, exactly like the built-in assertions. diff --git a/src/functions/assert/Exception/Should-Throw.ps1 b/src/functions/assert/Exception/Should-Throw.ps1 index 254a4f048..02e80c5b3 100644 --- a/src/functions/assert/Exception/Should-Throw.ps1 +++ b/src/functions/assert/Exception/Should-Throw.ps1 @@ -110,10 +110,19 @@ } $filterOnMessage = -not ([string]::IsNullOrWhiteSpace($ExceptionMessage)) + $messageFailedOnWildcard = $false if ($filterOnMessage) { - $filters += "with message like '$([System.Management.Automation.WildcardPattern]::Unescape($ExceptionMessage))'" + $unescapedExceptionMessage = [System.Management.Automation.WildcardPattern]::Unescape($ExceptionMessage) + $filters += "with message like '$unescapedExceptionMessage'" if ($err.ExceptionMessage -notlike $ExceptionMessage) { $buts += "the message was '$($err.ExceptionMessage)'" + # -ExceptionMessage matches with -like. When the actual message is identical to the + # expected one treated literally, the only reason the match failed is unescaped wildcard + # characters ([ ] * ?) in -ExceptionMessage. Flag it so the failure message is not + # baffling (#2968, #1793). + if ($err.ExceptionMessage -eq $unescapedExceptionMessage) { + $messageFailedOnWildcard = $true + } } } @@ -133,7 +142,11 @@ $filter = Add-SpaceToNonEmptyString ( Join-And $filters -Threshold 3 ) $but = Join-And $buts $defaultMessage = "Expected an exception,$filter to be thrown, but $but." - $assert.Fail($defaultMessage, @{ Because = $Because }) + $data = @{ Because = $Because } + if ($messageFailedOnWildcard) { + $data['Hint'] = "-ExceptionMessage matches using wildcards (-like). The messages are identical except for the wildcard characters [ ] * ? in -ExceptionMessage. Escape them with a backtick (``[) or use [System.Management.Automation.WildcardPattern]::Escape() to match them literally." + } + $assert.Fail($defaultMessage, $data) } $err.ErrorRecord diff --git a/tst/functions/assert/Common/New-ShouldAssertion.Tests.ps1 b/tst/functions/assert/Common/New-ShouldAssertion.Tests.ps1 index ddd918ac4..9e10294bc 100644 --- a/tst/functions/assert/Common/New-ShouldAssertion.Tests.ps1 +++ b/tst/functions/assert/Common/New-ShouldAssertion.Tests.ps1 @@ -119,6 +119,25 @@ Describe "New-ShouldAssertion" { $err = { 'lame' | Should-BeAwesome } | Verify-AssertionFailed ($err.Exception.Message -notlike '*Hint:*') | Verify-True } + + It "uses an explicit Hint from the data instead of the default input hint" { + function Should-BeAwesomeHinted { + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseProcessBlockForPipelineCommand', '')] + [CmdletBinding()] + param ( + [Parameter(Position = 1, ValueFromPipeline = $true)] $Actual, + [Parameter(Position = 0)] $Expected = 'Awesome' + ) + $assert = New-ShouldAssertion -Caller $PSCmdlet -Actual $Actual -Buffer $local:Input + $assert.Fail("Expected but got .", @{ Expected = $Expected; Hint = 'try harder' }) + } + + # Even a multi-item collection, which would normally produce the default single-value + # hint, gets the assertion's own hint instead. + $err = { 1, 2, 3 | Should-BeAwesomeHinted } | Verify-AssertionFailed + $err.Exception.Message | Verify-Like '*Hint: try harder' + ($err.Exception.Message -like '*single-value assertion*') | Verify-False + } } Context "Message tokens" { diff --git a/tst/functions/assert/Exception/Should-Throw.Tests.ps1 b/tst/functions/assert/Exception/Should-Throw.Tests.ps1 index a1055beef..41cb50b1e 100644 --- a/tst/functions/assert/Exception/Should-Throw.Tests.ps1 +++ b/tst/functions/assert/Exception/Should-Throw.Tests.ps1 @@ -140,6 +140,20 @@ Describe "Should-Throw" { $err = { { throw [ArgumentException]"[!]" } | Should-Throw -ExceptionMessage '`[`]' } | Verify-AssertionFailed $err.Exception.Message | Verify-Equal "Expected an exception, with message like '[]' to be thrown, but the message was '[!]'." } + + It "Hints at wildcard matching when the message is identical except for unescaped wildcard characters (#2968)" { + # -ExceptionMessage matches with -like, so [ ] * ? are wildcards. When the actual message + # is identical to the expected one treated literally, the match failed only because those + # characters were not escaped. The hint points that out instead of showing two + # identical-looking messages. + $err = { { throw 'value is [1]' } | Should-Throw -ExceptionMessage 'value is [1]' } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected an exception, with message like 'value is [1]' to be thrown, but the message was 'value is [1]'.`n`nHint: -ExceptionMessage matches using wildcards (-like). The messages are identical except for the wildcard characters [ ] * ? in -ExceptionMessage. Escape them with a backtick (``[) or use [System.Management.Automation.WildcardPattern]::Escape() to match them literally." + } + + It "Does not hint at wildcard matching when the messages genuinely differ" { + $err = { { throw [ArgumentException]"fail!" } | Should-Throw -ExceptionMessage 'halt!' } | Verify-AssertionFailed + ($err.Exception.Message -like '*matches using wildcards*') | Verify-False + } } Context "Unwrapping exception from different sources" { From fdc15cffb8852cb895da94ff70f43f255d8aaed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Tue, 11 Aug 2026 19:18:47 +0200 Subject: [PATCH 2/2] Update src/functions/assert/Common/New-ShouldAssertion.ps1 Co-authored-by: Frode Flaten <3436158+fflaten@users.noreply.github.com> --- src/functions/assert/Common/New-ShouldAssertion.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions/assert/Common/New-ShouldAssertion.ps1 b/src/functions/assert/Common/New-ShouldAssertion.ps1 index d4386bbbe..3a1fdd1fd 100644 --- a/src/functions/assert/Common/New-ShouldAssertion.ps1 +++ b/src/functions/assert/Common/New-ShouldAssertion.ps1 @@ -83,7 +83,7 @@ class ShouldAssertion { # Everything except the reserved keys becomes a token in the message. $extra = @{} foreach ($key in $Data.Keys) { - if ($key -ne 'Expected' -and $key -ne 'Actual' -and $key -ne 'Because' -and $key -ne 'Hint') { + if ($key -notin 'Expected', 'Actual', 'Because', 'Hint') { $extra[$key] = $Data[$key] } }