diff --git a/src/functions/assert/Common/New-ShouldAssertion.ps1 b/src/functions/assert/Common/New-ShouldAssertion.ps1 index 928b7c3cd..3a1fdd1fd 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 -notin 'Expected', 'Actual', 'Because', '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" {