From 19af4115fd174a1db66bb49ea19f50829687275b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Fri, 24 Jul 2026 00:10:39 +0200 Subject: [PATCH] Report the real exception type from Should-Throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Should-Throw runs the scriptblock with InvokeWithContext, so a terminating error comes back wrapped in a MethodInvocationException. Get-ErrorObject unwrapped it to the inner error record and reported its Exception, which for a parameter binding failure (and a division by zero) is only a ParentContainsErrorRecordException placeholder, not the real exception. So Should-Throw -ExceptionType reported the wrong type, unlike Should -Throw. Get-ErrorObject now reports the same exception that & { } would surface in $_: the inner exception itself when the record only carries the placeholder, and the record's own exception otherwise (Write-Error -ErrorAction Stop and Get-Item -ErrorAction Stop wrap the original record in an ActionPreferenceStopException, so those keep working). Fix #2873 🤖 --- .../assert/Exception/Should-Throw.ps1 | 46 +++++++++++++++---- .../assert/Exception/Should-Throw.Tests.ps1 | 40 ++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/functions/assert/Exception/Should-Throw.ps1 b/src/functions/assert/Exception/Should-Throw.ps1 index 27b1809cf..54243901a 100644 --- a/src/functions/assert/Exception/Should-Throw.ps1 +++ b/src/functions/assert/Exception/Should-Throw.ps1 @@ -143,17 +143,45 @@ function Get-ErrorObject ($ErrorRecord) { if ($ErrorRecord.Exception -like '*"InvokeWithContext"*') { - $e = $ErrorRecord.Exception.InnerException.ErrorRecord + # The scriptblock ran via InvokeWithContext, so a terminating error is wrapped in + # a MethodInvocationException. Unwrap it so we report the same exception that + # & { } would have surfaced in $_ (#2873). + $inner = $ErrorRecord.Exception.InnerException + $record = $inner.ErrorRecord + + # When the inner exception carries an error record whose own exception is just a + # ParentContainsErrorRecordException placeholder (e.g. a parameter binding failure, + # or a division by zero), the real exception is the inner exception itself. + # Otherwise the record's exception is the one the caller threw - for example + # Write-Error -ErrorAction Stop wraps the original record in an + # ActionPreferenceStopException, and Get-Item -ErrorAction Stop does the same. + if ($null -eq $record) { + $realException = $inner + $record = $ErrorRecord + } + elseif ($record.Exception -is [System.Management.Automation.ParentContainsErrorRecordException]) { + $realException = $inner + } + else { + $realException = $record.Exception + } + + [PSCustomObject] @{ + ErrorRecord = $record + ExceptionMessage = $realException.Message + Exception = $realException + ExceptionType = $realException.GetType() + FullyQualifiedErrorId = $record.FullyQualifiedErrorId + } } else { - $e = $ErrorRecord - } - [PSCustomObject] @{ - ErrorRecord = $e - ExceptionMessage = $e.Exception.Message - Exception = $e.Exception - ExceptionType = $e.Exception.GetType() - FullyQualifiedErrorId = $e.FullyQualifiedErrorId + [PSCustomObject] @{ + ErrorRecord = $ErrorRecord + ExceptionMessage = $ErrorRecord.Exception.Message + Exception = $ErrorRecord.Exception + ExceptionType = $ErrorRecord.Exception.GetType() + FullyQualifiedErrorId = $ErrorRecord.FullyQualifiedErrorId + } } } diff --git a/tst/functions/assert/Exception/Should-Throw.Tests.ps1 b/tst/functions/assert/Exception/Should-Throw.Tests.ps1 index cf0c516a9..ad3bc79b9 100644 --- a/tst/functions/assert/Exception/Should-Throw.Tests.ps1 +++ b/tst/functions/assert/Exception/Should-Throw.Tests.ps1 @@ -40,6 +40,12 @@ Describe "Should-Throw" { It "Fails when exception is thrown, but is not the expected type nor iheriting form the expected type" { { { throw [InvalidOperationException]"This operation is invalid!" } | Should-Throw -ExceptionType ([ArgumentException]) } | Verify-AssertionFailed } + + It "Matches the real exception type for a parameter binding failure (#2873)" { + # Used to report ParentContainsErrorRecordException instead of the real type. + { Get-Item "/non-existing" -NonExistentParameter 1 } | + Should-Throw -ExceptionType ([System.Management.Automation.ParameterBindingException]) + } } Context "Filtering with exception message" { @@ -242,5 +248,39 @@ InPesterModuleScope { $err.ExceptionType | Verify-Equal ([Management.Automation.ItemNotFoundException]) $err.FullyQualifiedErrorId | Verify-Equal 'PathNotFound,Microsoft.PowerShell.Commands.GetItemCommand' } + + # #2873 - the exception type must match what & { } would surface in $_, not the + # ParentContainsErrorRecordException placeholder that some inner error records carry. + BeforeAll { + function Get-ThrownError ([scriptblock] $ScriptBlock) { + try { + $eap = [PSVariable]::new("erroractionpreference", 'Stop') + $null = $ScriptBlock.InvokeWithContext($null, $eap, $null) 2>&1 + } + catch { + return Get-ErrorObject $_ + } + } + } + + It 'Reports the real exception type for a parameter binding failure (#2873)' { + $err = Get-ThrownError { Get-Item "/non-existing" -NonExistentParameter 1 } + $err.ExceptionType | Verify-Equal ([System.Management.Automation.ParameterBindingException]) + } + + It 'Reports the real exception type for a division by zero (#2873)' { + $err = Get-ThrownError { 1 / $null } + $err.ExceptionType | Verify-Equal ([System.Management.Automation.RuntimeException]) + } + + It 'Reports the wrapped exception for Write-Error -ErrorAction Stop (#2873)' { + $err = Get-ThrownError { Write-Error -ErrorAction Stop -Exception ([System.ArgumentException]"boom") } + $err.ExceptionType | Verify-Equal ([System.ArgumentException]) + } + + It 'Reports the wrapped exception for a cmdlet with -ErrorAction Stop (#2873)' { + $err = Get-ThrownError { Get-Item "/non-existing" -ErrorAction Stop } + $err.ExceptionType | Verify-Equal ([System.Management.Automation.ItemNotFoundException]) + } } }