diff --git a/src/functions/assert/Parameter/Should-HaveParameter.ps1 b/src/functions/assert/Parameter/Should-HaveParameter.ps1 index beb67f966..e3b005907 100644 --- a/src/functions/assert/Parameter/Should-HaveParameter.ps1 +++ b/src/functions/assert/Parameter/Should-HaveParameter.ps1 @@ -15,6 +15,16 @@ .PARAMETER DefaultValue The default value of the parameter to check. E.g. "https://example.com" + .PARAMETER DefaultValueType + The .NET type of the parameter's default value, as a type or a type name (the same way -Type is given), + e.g. `([string])`, `[int]`, `bool` or `datetime`. This matches literal defaults whose type is known, so + `$Force = $false` is `[bool]` and `$Retries = 3` is `[int]`. + Pass the special value `Expression` for a computed default whose type is not known until it runs, e.g. + `(Get-Date)`, `[datetime]::Now` or `$someVariable`. `Expression` vs a concrete type is what tells an + expression default apart from a literal string default, which -DefaultValue (a string comparison) + cannot: for example `$Path = (Get-DefaultPath)` is an `Expression` while `$Path = '(Get-DefaultPath)'` + is `[string]`. + .PARAMETER Mandatory Whether the parameter is mandatory or not. @@ -99,6 +109,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, + $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, [Switch] $HasArgumentCompleter, diff --git a/src/functions/assertions/HaveParameter.ps1 b/src/functions/assertions/HaveParameter.ps1 index c7924d029..eb193fa5b 100644 --- a/src/functions/assertions/HaveParameter.ps1 +++ b/src/functions/assertions/HaveParameter.ps1 @@ -3,6 +3,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, + $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, [Switch] $HasArgumentCompleter, @@ -53,7 +54,7 @@ if ($null -eq $ast) { # Ast is unavailable, ex. for a binary cmdlet - throw [ArgumentException]'Using -DefaultValue is only supported for functions and scripts.' + throw [ArgumentException]'Using -DefaultValue or -DefaultValueType is only supported for functions and scripts.' } if ($null -ne $ast.Parameters) { @@ -78,11 +79,12 @@ } $paramInfo = [PSCustomObject] @{ - Name = $parameter.Name.VariablePath.UserPath - Type = "[$($parameter.StaticType.Name.ToLower())]" - HasDefaultValue = $false - DefaultValue = $null - DefaultValueType = $parameter.StaticType.Name + Name = $parameter.Name.VariablePath.UserPath + Type = "[$($parameter.StaticType.Name.ToLower())]" + HasDefaultValue = $false + DefaultValue = $null + DefaultValueType = $null + DefaultValueIsExpression = $false } # Default value here contains a descriptor object of the default value, @@ -99,6 +101,14 @@ # we take that and use it, otherwise we take the extent (how it was written in code). This will make # 1, 2, or "abc", appear as 1, 2, abc to the assertion, but (Get-Date) will be (Get-Date). $paramInfo.DefaultValue = Get-DefaultValue $parameter.DefaultValue + # The .NET type of the default value when the parser can type it statically (a literal like + # 1, 'x', $true, { }, @{ }), and whether the default is a computed expression like (Get-Date) + # whose type is not known until it runs. -DefaultValueType asserts against these: a concrete + # type for a literal, or Expression for a computed default. Expression vs a concrete type is + # what tells an expression default apart from a literal that -DefaultValue cannot. + $defaultValueTypeInfo = Get-DefaultValueTypeInfo $parameter.DefaultValue + $paramInfo.DefaultValueType = $defaultValueTypeInfo.Type + $paramInfo.DefaultValueIsExpression = $defaultValueTypeInfo.IsExpression } $paramInfo @@ -132,6 +142,35 @@ $DefaultValue.Extent.Text } + function Get-DefaultValueTypeInfo { + # Resolves the value type of a parameter's default. Returns .Type, the actual .NET type when the + # parser can type it statically (a literal like 1, 'x', $true, { }, @{ }), and .IsExpression, which + # is $true when the default is a computed expression like (Get-Date) or [datetime]::Now whose type + # is only known when it runs. A computed expression is what tells a default apart from a literal + # that happens to have the same -DefaultValue text. A $null default has neither a type nor is an + # expression. + param($DefaultValue) + + # $true / $false have a known type; $null has no type; any other variable ($var, $env:PATH) is a + # computed reference whose type we cannot know without running it. + if ($DefaultValue -is [System.Management.Automation.Language.VariableExpressionAst]) { + $variableName = $DefaultValue.VariablePath.UserPath + if ('true' -eq $variableName -or 'false' -eq $variableName) { return @{ Type = [bool]; IsExpression = $false } } + if ('null' -eq $variableName) { return @{ Type = $null; IsExpression = $false } } + return @{ Type = $null; IsExpression = $true } + } + + # System.Object means the parser could not determine a type, i.e. the default is a computed + # expression such as (Get-Date), [datetime]::Now or a pipeline. Anything else (String, Int32, + # Double, ScriptBlock, Hashtable, Object[], ...) is a literal we can type. + $staticType = $DefaultValue.StaticType + if ($null -eq $staticType -or [object] -eq $staticType) { + return @{ Type = $null; IsExpression = $true } + } + + return @{ Type = $staticType; IsExpression = $false } + } + function Get-ArgumentCompleter { <# .SYNOPSIS @@ -240,7 +279,7 @@ elseif ($Negate -and -not $hasKey) { return [Pester.ShouldResult] @{ Succeeded = $true } } - elseif ($Negate -and $hasKey -and -not ($InParameterSet -or $Mandatory -or $Type -or $DefaultValue -or $HasArgumentCompleter)) { + elseif ($Negate -and $hasKey -and -not ($InParameterSet -or $Mandatory -or $Type -or $DefaultValue -or $DefaultValueType -or $HasArgumentCompleter)) { $buts += 'the parameter exists' } else { @@ -329,6 +368,59 @@ } } + if ($PSBoundParameters.ContainsKey('DefaultValueType')) { + $parameterMetadata = Get-ParameterInfo -Name $ParameterName -Command $ActualValue + if ($null -eq $parameterMetadata) { + # For safety, but this probably won't happen because if the parameter is not on the command we will fail much sooner. + throw "Metadata for parameter '$ParameterName' were not found." + } + + # -DefaultValueType takes a .NET type (as a [type] or a string like 'string', same as -Type), + # which is matched against the value type of a literal default, or the special value 'Expression' + # for a computed default like (Get-Date) whose type is not known until it runs. Expression vs a + # concrete type is what tells an expression default apart from a literal string default that + # -DefaultValue (a string comparison) cannot distinguish. + $expectExpression = ($DefaultValueType -is [string]) -and ('Expression' -eq $DefaultValueType) + + # Describe the actual default the same way for every mismatch below. + $actualDefaultDescription = + if (-not $parameterMetadata.HasDefaultValue) { 'the parameter had no default value' } + elseif ($parameterMetadata.DefaultValueIsExpression) { 'the default value was an expression' } + elseif ($null -eq $parameterMetadata.DefaultValueType) { 'the default value was $null' } + else { "the default value was of type [$($parameterMetadata.DefaultValueType.FullName)]" } + + if ($expectExpression) { + $filters += "the default value$(if ($Negate) {' not'}) to be an expression" + $testDefaultValueType = $parameterMetadata.HasDefaultValue -and $parameterMetadata.DefaultValueIsExpression + $negatedBut = 'the default value was an expression' + } + else { + # Resolve the expected type, parsing a string type name the way -Type does. + if ($DefaultValueType -is [string]) { + $trimmedType = $DefaultValueType -replace '^\[(.*)\]$', '$1' + [type] $expectedType = $trimmedType -as [Type] + if ($null -eq $expectedType) { + throw [ArgumentException]"Could not find type [$trimmedType]. Make sure that the assembly that contains that type is loaded, or use -DefaultValueType Expression to check for a computed default value." + } + } + else { + [type] $expectedType = $DefaultValueType + } + + $filters += "the default value$(if ($Negate) {' not'}) to be of type [$($expectedType.FullName)]" + $actualType = $parameterMetadata.DefaultValueType + $testDefaultValueType = ($null -ne $actualType) -and ($expectedType -eq $actualType) + $negatedBut = "the default value was of type [$($expectedType.FullName)]" + } + + if (-not $Negate -and -not $testDefaultValueType) { + $buts += $actualDefaultDescription + } + elseif ($Negate -and $testDefaultValueType) { + $buts += $negatedBut + } + } + if ($PSBoundParameters.ContainsKey('HasArgumentCompleter')) { $testArgumentCompleter = $attributes | & $SafeCommands['Where-Object'] { $_ -is [ArgumentCompleter] } diff --git a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 index 396634132..8c36dc19c 100644 --- a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 +++ b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 @@ -108,4 +108,69 @@ Describe "Should-HaveParameter" { Get-Command f | Should-HaveParameter a -HasArgumentCompleter:$false } } + + Context "DefaultValueType" { + It "Passes when the default value is an expression and Expression is expected" { + function f { + param([string] $Path = (Get-Date)) + } + + Get-Command f | Should-HaveParameter Path -DefaultValueType Expression + } + + It "Passes when the default value is a literal string and its type is expected" { + function f { + param([string] $Path = '(Get-Date)') + } + + Get-Command f | Should-HaveParameter Path -DefaultValueType String + Get-Command f | Should-HaveParameter Path -DefaultValueType ([string]) + } + + It "Reports the real type, so a `$true default is [bool] and a number is [int]" { + function f { + param($Enabled = $true, $Retries = 3) + } + + Get-Command f | Should-HaveParameter Enabled -DefaultValueType ([bool]) + Get-Command f | Should-HaveParameter Retries -DefaultValueType int + } + + It "Distinguishes a string-literal default from an expression default" { + function f { + param( + [string] $Literal = '(Get-Date)', + [string] $Expression = (Get-Date) + ) + } + + # Same -DefaultValue string, different -DefaultValueType (issue #1888) + Get-Command f | Should-HaveParameter Literal -DefaultValue '(Get-Date)' -DefaultValueType String + Get-Command f | Should-HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType Expression + } + + It "Fails when the default value type does not match" { + function f { + param([string] $Path = '(Get-Date)') + } + + { Get-Command f | Should-HaveParameter Path -DefaultValueType Expression } | Verify-Throw + } + + It "Fails when the parameter has no default value" { + function f { + param([string] $Path) + } + + { Get-Command f | Should-HaveParameter Path -DefaultValueType String } | Verify-Throw + } + + It "Throws when given a type name that does not exist" { + function f { + param([string] $Path = '(Get-Date)') + } + + { Get-Command f | Should-HaveParameter Path -DefaultValueType NotAType } | Verify-Throw + } + } } diff --git a/tst/functions/assertions/HaveParameter.Tests.ps1 b/tst/functions/assertions/HaveParameter.Tests.ps1 index 99fd7a388..0c421d977 100644 --- a/tst/functions/assertions/HaveParameter.Tests.ps1 +++ b/tst/functions/assertions/HaveParameter.Tests.ps1 @@ -287,7 +287,12 @@ InPesterModuleScope { It 'fails if the parameter DefaultValue is used with a binary cmdlet' { $err = { Get-Command 'Get-Content' | Should -HaveParameter Force -DefaultValue $False } | Verify-Throw - $err.Exception.Message | Verify-Equal 'Using -DefaultValue is only supported for functions and scripts.' + $err.Exception.Message | Verify-Equal 'Using -DefaultValue or -DefaultValueType is only supported for functions and scripts.' + } + + It 'fails if the parameter DefaultValueType is used with a binary cmdlet' { + $err = { Get-Command 'Get-Content' | Should -HaveParameter Force -DefaultValueType Boolean } | Verify-Throw + $err.Exception.Message | Verify-Equal 'Using -DefaultValue or -DefaultValueType is only supported for functions and scripts.' } It "fails if the parameter MandatoryParam has no alias 'Second'" { @@ -384,6 +389,100 @@ InPesterModuleScope { } } + Context 'Using DefaultValueType' { + BeforeAll { + function Test-DefaultValueType { + param ( + [string] $LiteralString = '(Get-Date)', + [string] $Interpolated = "$x bar", + [string] $Expression = (Get-Date), + $Member = [datetime]::Now, + $Variable = $env:PATH, + $ScriptBlockDefault = { Get-Date }, + [int] $Number = 1, + $Double = 1.5, + $Bool = $true, + $BoolFalse = $false, + $Null = $null, + $ArrayDefault = @(1, 2), + $HashtableDefault = @{ a = 1 }, + $NoDefault + ) + } + } + + It "passes when the literal default is of type " -TestCases @( + @{ ParameterName = 'LiteralString'; DefaultValueType = 'string' } + @{ ParameterName = 'Interpolated'; DefaultValueType = 'string' } + @{ ParameterName = 'ScriptBlockDefault'; DefaultValueType = 'scriptblock' } + @{ ParameterName = 'Number'; DefaultValueType = 'int' } + @{ ParameterName = 'Number'; DefaultValueType = 'Int32' } + @{ ParameterName = 'Double'; DefaultValueType = 'double' } + @{ ParameterName = 'Bool'; DefaultValueType = 'bool' } + @{ ParameterName = 'BoolFalse'; DefaultValueType = 'bool' } + @{ ParameterName = 'HashtableDefault'; DefaultValueType = 'hashtable' } + @{ ParameterName = 'ArrayDefault'; DefaultValueType = 'object[]' } + ) { + Get-Command Test-DefaultValueType | Should -HaveParameter $ParameterName -DefaultValueType $DefaultValueType + } + + It "passes when the type is given as a type object rather than a string" { + Get-Command Test-DefaultValueType | Should -HaveParameter Number -DefaultValueType ([int]) + Get-Command Test-DefaultValueType | Should -HaveParameter Bool -DefaultValueType ([bool]) + Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType ([string]) + } + + It "passes when the computed default is an Expression" -TestCases @( + @{ ParameterName = 'Expression' } + @{ ParameterName = 'Member' } + @{ ParameterName = 'Variable' } + ) { + Get-Command Test-DefaultValueType | Should -HaveParameter $ParameterName -DefaultValueType Expression + } + + It "Expression matches case-insensitively" { + Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValueType expression + } + + It "distinguishes an expression default from a string-literal default" { + # This is the core scenario from issue #1888: (Get-Date) vs '(Get-Date)' share the same + # -DefaultValue string. The literal is a [string], the expression has no static type. + Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType Expression + Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue '(Get-Date)' -DefaultValueType String + } + + It "throws when given a type name that does not exist" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType NotAType } | Verify-Throw + $err.Exception | Verify-Type ([ArgumentException]) + $err.Exception.Message | Verify-Equal 'Could not find type [NotAType]. Make sure that the assembly that contains that type is loaded, or use -DefaultValueType Expression to check for a computed default value.' + } + + It "fails when the literal default is of a different type" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType ([int]) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be of type [System.Int32], but the default value was of type [System.String]." + } + + It "fails when a concrete type is expected but the default is an expression" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValueType ([string]) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter Expression, the default value to be of type [System.String], but the default value was an expression." + } + + It "fails when Expression is expected but the default is a literal" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType Expression } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be an expression, but the default value was of type [System.String]." + } + + It "fails when the parameter has no default value" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter NoDefault -DefaultValueType ([string]) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter NoDefault, the default value to be of type [System.String], but the parameter had no default value." + } + + It "returns a combined message when both DefaultValue and DefaultValueType differ" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue 'wrong' -DefaultValueType Expression -Because 'of reasons' } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be 'wrong' and the default value to be an expression, because of reasons, but the default value was '(Get-Date)' and the default value was of type [System.String]." + } + } + Context 'Using InParameterSet' { It "passes if parameter exist in parameter set " -TestCases @( @{ParameterName = 'ParamWithNotNullOrEmptyValidation'; ParameterSetName = 'PrimarySet' } @@ -540,6 +639,25 @@ InPesterModuleScope { { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter $ParameterName -DefaultValue $ExpectedValue } | Verify-AssertionFailed } + It "passes if the parameter default value type is not " -TestCases @( + @{ParameterName = "ParamWithNotNullOrEmptyValidation"; DefaultValueType = "String" } + @{ParameterName = "ParamWithScriptValidation"; DefaultValueType = "Expression" } + ) { + Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter $ParameterName -DefaultValueType $DefaultValueType + } + + It "fails if the parameter default value type is " -TestCases @( + @{ParameterName = "ParamWithNotNullOrEmptyValidation"; DefaultValueType = "Expression" } + @{ParameterName = "ParamWithScriptValidation"; DefaultValueType = "String" } + ) { + { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter $ParameterName -DefaultValueType $DefaultValueType } | Verify-AssertionFailed + } + + It "returns the correct assertion message when the default value type should not match but does" { + $err = { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter ParamWithScriptValidation -DefaultValueType String -Because 'of reasons' } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Invoke-DummyFunction to not have a parameter ParamWithScriptValidation, the default value not to be of type [System.String], because of reasons, but the default value was of type [System.String]." + } + It "fails if the parameter is of type or has a default value of ''" -TestCases @( @{ParameterName = "MandatoryParam"; ExpectedType = [Object]; ExpectedValue = "" } @{ParameterName = "ParamWithNotNullOrEmptyValidation"; ExpectedType = [DateTime]; ExpectedValue = "(Get-Date)" }