Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions src/functions/assertions/Be.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,27 @@ function ArraysAreEqual {
throw "Reached the recursion depth limit of $RecursionLimit when comparing arrays $First and $Second. Is one of your arrays cyclic?"
}

# Fast path for the overwhelmingly common case: each side is a single non-null value that
# PowerShell does not enumerate (numbers, strings, dates, hashtables, scriptblocks, ...),
# either bare or wrapped in a one-element object[] - which is what `1 | Should -Be 1` produces,
# because Should always hands the Be operator the collected pipeline array. Unwrapping a
# one-element object[] matches the full path exactly: @() leaves such an array as-is and the
# element loop below compares its single element. LanguagePrimitives.GetEnumerator is the same
# enumerability test @() uses, so anything it enumerates (arrays, lists, Keys/Values collections,
# enumerators) - including a one-element array wrapping another collection - still takes the full
# path. For two single values the full path always reaches the element comparison: the null/empty
# checks cannot match (one non-null element), the counts are both 1, and IsArray is false for
# both elements, ending in $Second -eq $First (-ceq when case sensitive) - which is what we do
# here without the @() allocations and four helper-function calls.
$singleFirst = if ($First -is [object[]] -and 1 -eq $First.Length) { $First[0] } else { $First }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can $First be [object[]] when the parameter declaration has [object]?

$singleSecond = if ($Second -is [object[]] -and 1 -eq $Second.Length) { $Second[0] } else { $Second }
if ($null -ne $singleFirst -and $null -ne $singleSecond -and
$null -eq [System.Management.Automation.LanguagePrimitives]::GetEnumerator($singleFirst) -and
$null -eq [System.Management.Automation.LanguagePrimitives]::GetEnumerator($singleSecond)) {
if ($CaseSensitive) { return $singleSecond -ceq $singleFirst }
return $singleSecond -eq $singleFirst
}

# Enumerate the inputs ourselves with @(). PowerShell's [object[]] parameter binding wraps a non-IList
# enumerable - such as the collection returned by [hashtable].Keys/.Values - into a single element instead
# of enumerating it, which made Should -Be report two equal collections as different (#1200). @() uses the
Expand Down Expand Up @@ -422,14 +443,11 @@ function ArraysAreEqual {
}
}
else {
if ($CaseSensitive) {
$comparer = { param($Actual, $Expected) $Expected -ceq $Actual }
}
else {
$comparer = { param($Actual, $Expected) $Expected -eq $Actual }
}

if (-not (& $comparer $First[$i] $Second[$i])) {
# Inline the element comparison. This previously built a fresh comparer scriptblock and
# invoked it (& $comparer) on every iteration even though $CaseSensitive is loop-invariant;
# the comparison itself is identical ($Expected -eq/-ceq $Actual, i.e. $Second[$i] vs $First[$i]).
$itemsEqual = if ($CaseSensitive) { $Second[$i] -ceq $First[$i] } else { $Second[$i] -eq $First[$i] }
if (-not $itemsEqual) {
return $false
}
}
Expand Down
55 changes: 34 additions & 21 deletions src/functions/assertions/Should.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,19 @@ function Should {
.LINK
https://pester.dev/docs/assertions
#>
# $lineNumber, $lineText, $file and $addErrorCallback are consumed by Test-AssertionResult
# through dynamic scoping, which the analyzer cannot see.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
[CmdletBinding()]
param (
[Parameter(ValueFromPipeline = $true, ValueFromRemainingArguments = $true)]
[object] $ActualValue
)

dynamicparam {
Get-AssertionDynamicParams
# Inlined Get-AssertionDynamicParams (whose whole body is `return $script:AssertionDynamicParams`)
# to save a function-call frame in the dynamicparam block, which PowerShell evaluates on every Should call.
$script:AssertionDynamicParams
}

begin {
Expand All @@ -90,9 +95,12 @@ function Should {
}

end {
$lineNumber = $MyInvocation.ScriptLineNumber
$lineText = $MyInvocation.Line.TrimEnd([System.Environment]::NewLine)
$file = $MyInvocation.ScriptName
# [int]/[string] typed to mirror the parameter types of Invoke-Assertion, whose body is inlined
# below; notably a $null ScriptName becomes '' exactly like binding $null to a [string] parameter,
# which keeps the "$null -eq $currentFile" fallback in Test-AssertionResult unreachable, as before.
[int] $lineNumber = $MyInvocation.ScriptLineNumber
[string] $lineText = $MyInvocation.Line.TrimEnd([System.Environment]::NewLine)
[string] $file = $MyInvocation.ScriptName

$negate = $false
if ($PSBoundParameters.ContainsKey('Not')) {
Expand All @@ -103,9 +111,15 @@ function Should {
$null = $PSBoundParameters.Remove($PSCmdlet.ParameterSetName)
$null = $PSBoundParameters.Remove('Not')

$entry = Get-AssertionOperatorEntry -Name $PSCmdlet.ParameterSetName
# Inlined Get-AssertionOperatorEntry (whose whole body is `return $script:AssertionOperators[$Name]`)
# to save a function-call frame per assertion. Should is a module function, so $script: resolves to
# the same module scope the helper reads.
$entry = $script:AssertionOperators[$PSCmdlet.ParameterSetName]

$shouldThrow = $null
# always defined so the dynamic-scope read in Test-AssertionResult (called below without the
# former Invoke-Assertion parameter layer) finds it even when no callback is set up
$addErrorCallback = $null
$errorActionIsDefined = $PSBoundParameters.ContainsKey("ErrorAction")
if ($errorActionIsDefined) {
$shouldThrow = 'Stop' -eq $PSBoundParameters["ErrorAction"]
Expand Down Expand Up @@ -153,39 +167,38 @@ function Should {
}
}

$assertionParams = @{
AssertionEntry = $entry
BoundParameters = $PSBoundParameters
File = $file
LineNumber = $lineNumber
LineText = $lineText
Negate = $negate
CallerSessionState = $PSCmdlet.SessionState
ShouldThrow = $shouldThrow
AddErrorCallback = $addErrorCallback
}

if (-not $entry) { return }

# The Invoke-Assertion body is inlined below: its 12-parameter binding (Mandatory + validation
# attributes) plus the splat hashtable cost ~100us per assertion, roughly half of a passing
# `Should -Be`. Test-AssertionResult keeps reading $file/$lineNumber/$lineText/$shouldThrow/
# $addErrorCallback from this scope via dynamic scoping, exactly as it read the equally-named
# Invoke-Assertion parameters before. Invoke-Assertion itself stays defined for other callers.
$callerSessionState = $PSCmdlet.SessionState

if ($inputArray.Count -eq 0) {
Invoke-Assertion @assertionParams -ValueToTest $null
$testResult = & $entry.Test -ActualValue $null -Negate:$negate -CallerSessionState $callerSessionState @PSBoundParameters
Test-AssertionResult $testResult
}
elseif ($entry.SupportsArrayInput) {
if ($MyInvocation.ExpectingInput) {
# Pipeline input is collected item-by-item in the process block, so pass the collected array.
Invoke-Assertion @assertionParams -ValueToTest $inputArray.ToArray()
$testResult = & $entry.Test -ActualValue ($inputArray.ToArray()) -Negate:$negate -CallerSessionState $callerSessionState @PSBoundParameters
Test-AssertionResult $testResult
}
else {
# The value was supplied by parameter instead (Should -ActualValue @(1,2,3), which is also what
# splatting does). The process block ran once and $inputArray wrapped the whole value into a
# single element; enumerate the original $ActualValue with @() so it matches what the pipeline
# would have produced, rather than being wrapped one level too deep. (#2314)
Invoke-Assertion @assertionParams -ValueToTest @($ActualValue)
$testResult = & $entry.Test -ActualValue (@($ActualValue)) -Negate:$negate -CallerSessionState $callerSessionState @PSBoundParameters
Test-AssertionResult $testResult
}
}
else {
foreach ($object in $inputArray) {
Invoke-Assertion @assertionParams -ValueToTest $object
$testResult = & $entry.Test -ActualValue $object -Negate:$negate -CallerSessionState $callerSessionState @PSBoundParameters
Test-AssertionResult $testResult
}
}
}
Expand Down
Loading