diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index d984a8ef3..46fdfbef5 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -14,6 +14,7 @@ permissions: jobs: backport: - uses: dotnet/arcade/.github/workflows/backport-base.yml@main + uses: dotnet/arcade/.github/workflows/backport-base.yml@backport-just-author with: repository_owners: 'pester' + pr_title_template: '%source_pr_title% by @%source_pr_author% in #%source_pr_number% (backport to %target_branch%)' diff --git a/BACKERS.md b/BACKERS.md index 2ef2ac64b..493efa14b 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -18,3 +18,5 @@ @tabs-not-spaces @avanreijn @DevOpsCollectiveINC +@chocolatey + diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c9a22f910..9261634f0 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -82,12 +82,12 @@ stages: PS7_Ubuntu_22_04: vmImage: ubuntu-22.04 pwsh: true - PS7_macOS_12: - vmImage: macOS-12 - pwsh: true PS7_macOS_13: vmImage: macOS-13 pwsh: true + PS7_macOS_14: + vmImage: macOS-14 + pwsh: true PS7_Windows_Server2019: vmImage: windows-2019 pwsh: true @@ -114,9 +114,8 @@ stages: script: | & ./test.ps1 -CI -CC -PassThru -NoBuild workingDirectory: '$(Build.SourcesDirectory)' - - task: PublishCodeCoverageResults@1 + - task: PublishCodeCoverageResults@2 inputs: - codeCoverageTool: 'JaCoCo' summaryFileLocation: 'coverage.xml' pathToSources: '$(Build.SourcesDirectory)/bin/' failIfCoverageEmpty: false diff --git a/build.ps1 b/build.ps1 index 254e8484a..7587e1a33 100644 --- a/build.ps1 +++ b/build.ps1 @@ -94,7 +94,7 @@ if ($Clean) { if ($Clean) { # Update PesterConfiguration help in about_PesterConfiguration if ($PSVersionTable.PSVersion.Major -ge 6) { - $null = [Reflection.Assembly]::LoadFrom("$PSScriptRoot/src/csharp/Pester/bin/$Configuration/net6.0/Pester.dll") + $null = [Reflection.Assembly]::LoadFrom("$PSScriptRoot/src/csharp/Pester/bin/$Configuration/net8.0/Pester.dll") } else { $null = [Reflection.Assembly]::LoadFrom("$PSScriptRoot/src/csharp/Pester/bin/$Configuration/net462/Pester.dll") @@ -202,9 +202,10 @@ if ($Clean) { , ("$PSScriptRoot/src/schemas/JUnit4/*.xsd", "$PSScriptRoot/bin/schemas/JUnit4/") , ("$PSScriptRoot/src/schemas/NUnit25/*.xsd", "$PSScriptRoot/bin/schemas/NUnit25/") , ("$PSScriptRoot/src/schemas/NUnit3/*.xsd", "$PSScriptRoot/bin/schemas/NUnit3/") + , ("$PSScriptRoot/src/schemas/Cobertura/*.dtd", "$PSScriptRoot/bin/schemas/Cobertura/") , ("$PSScriptRoot/src/schemas/JaCoCo/*.dtd", "$PSScriptRoot/bin/schemas/JaCoCo/") , ("$PSScriptRoot/src/csharp/Pester/bin/$Configuration/net462/Pester.dll", "$PSScriptRoot/bin/bin/net462/") - , ("$PSScriptRoot/src/csharp/Pester/bin/$Configuration/net6.0/Pester.dll", "$PSScriptRoot/bin/bin/net6.0/") + , ("$PSScriptRoot/src/csharp/Pester/bin/$Configuration/net8.0/Pester.dll", "$PSScriptRoot/bin/bin/net8.0/") ) } diff --git a/publish/filesToPublish.ps1 b/publish/filesToPublish.ps1 index 6ae35efc8..ca9f45457 100644 --- a/publish/filesToPublish.ps1 +++ b/publish/filesToPublish.ps1 @@ -5,9 +5,10 @@ 'Pester.Format.ps1xml' 'PesterConfiguration.Format.ps1xml' 'bin/net462/Pester.dll' - 'bin/net6.0/Pester.dll' + 'bin/net8.0/Pester.dll' 'en-US/about_Pester.help.txt' 'en-US/about_PesterConfiguration.help.txt' + 'schemas/Cobertura/coverage-loose.dtd' 'schemas/JaCoCo/report.dtd' 'schemas/JUnit4/junit_schema_4.xsd' 'schemas/NUnit25/nunit_schema_2.5.xsd' diff --git a/src/Main.ps1 b/src/Main.ps1 index 900978ef2..2c71b497f 100644 --- a/src/Main.ps1 +++ b/src/Main.ps1 @@ -80,6 +80,8 @@ function Add-ShouldOperator { [switch] $SupportsArrayInput ) + Assert-BoundScriptBlockInput -ScriptBlock $Test + $entry = [PSCustomObject]@{ Test = $Test SupportsArrayInput = [bool]$SupportsArrayInput @@ -1260,6 +1262,8 @@ function BeforeDiscovery { [ScriptBlock]$ScriptBlock ) + Assert-BoundScriptBlockInput -ScriptBlock $ScriptBlock + if ($ExecutionContext.SessionState.PSVariable.Get('invokedViaInvokePester')) { if ($state.CurrentBlock.IsRoot -and -not $state.CurrentBlock.FrameworkData.MissingParametersProcessed) { # For undefined parameters in container, add parameter's default value to Data diff --git a/src/Pester.RSpec.ps1 b/src/Pester.RSpec.ps1 index 633557cf5..e15094b98 100644 --- a/src/Pester.RSpec.ps1 +++ b/src/Pester.RSpec.ps1 @@ -333,9 +333,6 @@ function New-PesterConfiguration { .LINK https://pester.dev/docs/commands/Invoke-Pester - - .LINK - about_PesterConfiguration #> [CmdletBinding()] [OutputType([PesterConfiguration])] diff --git a/src/Pester.Runtime.ps1 b/src/Pester.Runtime.ps1 index 965ac9ab2..40ef93a56 100644 --- a/src/Pester.Runtime.ps1 +++ b/src/Pester.Runtime.ps1 @@ -2474,6 +2474,10 @@ function New-BlockContainerObject { default { throw [System.ArgumentOutOfRangeException]'' } } + if ($item -is [scriptblock]) { + Assert-BoundScriptBlockInput -ScriptBlock $item + } + $c = [Pester.ContainerInfo]::Create() $c.Type = $type $c.Item = $item @@ -2604,3 +2608,20 @@ function Add-MissingContainerParameters ($RootBlock, $Container, $CallingFunctio $RootBlock.FrameworkData.MissingParametersProcessed = $true } + +function Assert-BoundScriptBlockInput { + param( + [Parameter(Mandatory = $true)] + [ScriptBlock] $ScriptBlock + ) + $internalSessionState = $script:ScriptBlockSessionStateInternalProperty.GetValue($ScriptBlock, $null) + if ($null -eq $internalSessionState) { + $maxLength = 250 + $prettySb = (Format-Nicely2 $ScriptBlock) -replace '\s{2,}', ' ' + if ($prettySb.Length -gt $maxLength) { + $prettySb = "$($prettySb.Remove($maxLength))..." + } + + throw [System.ArgumentException]::new("Unbound scriptblock is not allowed, because it would run inside of Pester session state and produce unexpected results. See https://github.com/pester/Pester/issues/2411 for more details and workarounds. ScriptBlock: '$prettySb'") + } +} diff --git a/src/Pester.Types.ps1 b/src/Pester.Types.ps1 index d891dddaf..b63d20d43 100644 --- a/src/Pester.Types.ps1 +++ b/src/Pester.Types.ps1 @@ -13,13 +13,13 @@ if ($null -ne $configurationType) { } if ($PSVersionTable.PSVersion.Major -ge 6) { - $path = "$PSScriptRoot/bin/net6.0/Pester.dll" + $path = "$PSScriptRoot/bin/net8.0/Pester.dll" # PESTER_BUILD if ((Get-Variable -Name "PESTER_BUILD" -ValueOnly -ErrorAction Ignore)) { - $path = "$PSScriptRoot/../bin/bin/net6.0/Pester.dll" + $path = "$PSScriptRoot/../bin/bin/net8.0/Pester.dll" } else { - $path = "$PSScriptRoot/../bin/bin/net6.0/Pester.dll" + $path = "$PSScriptRoot/../bin/bin/net8.0/Pester.dll" } # end PESTER_BUILD & $SafeCommands['Add-Type'] -Path $path diff --git a/src/Pester.psd1 b/src/Pester.psd1 index 3ae04f666..7d574845a 100644 --- a/src/Pester.psd1 +++ b/src/Pester.psd1 @@ -161,10 +161,10 @@ LicenseUri = "https://www.apache.org/licenses/LICENSE-2.0.html" # Release notes for this particular version of the module - ReleaseNotes = 'https://github.com/pester/Pester/releases/tag/6.0.0-alpha4' + ReleaseNotes = 'https://github.com/pester/Pester/releases/tag/6.0.0-alpha5' # Prerelease string of this module - Prerelease = 'alpha4' + Prerelease = 'alpha5' } # Minimum assembly version required diff --git a/src/csharp/Pester/CodeCoverageConfiguration.cs b/src/csharp/Pester/CodeCoverageConfiguration.cs index b43d7ece3..1f1b3eda0 100644 --- a/src/csharp/Pester/CodeCoverageConfiguration.cs +++ b/src/csharp/Pester/CodeCoverageConfiguration.cs @@ -41,7 +41,7 @@ public static CodeCoverageConfiguration ShallowClone(CodeCoverageConfiguration c public CodeCoverageConfiguration() : base("Options to enable and configure Pester's code coverage feature.") { Enabled = new BoolOption("Enable CodeCoverage.", false); - OutputFormat = new StringOption("Format to use for code coverage report. Possible values: JaCoCo, CoverageGutters", "JaCoCo"); + OutputFormat = new StringOption("Format to use for code coverage report. Possible values: JaCoCo, CoverageGutters, Cobertura", "JaCoCo"); OutputPath = new StringOption("Path relative to the current directory where code coverage report is saved.", "coverage.xml"); OutputEncoding = new StringOption("Encoding of the output file.", "UTF8"); Path = new StringArrayOption("Directories or files to be used for code coverage, by default the Path(s) from general settings are used, unless overridden here.", new string[0]); diff --git a/src/csharp/Pester/CoverageLocationVisitor.cs b/src/csharp/Pester/CoverageLocationVisitor.cs new file mode 100644 index 000000000..46e1362f0 --- /dev/null +++ b/src/csharp/Pester/CoverageLocationVisitor.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Management.Automation.Language; + +namespace Pester +{ + /// + /// A visitor class for traversing the PowerShell AST to collect coverage-relevant locations. + /// This replaces predicate-based filtering with a centralized, extensible approach. + /// + /// Advantages: + /// - Efficiently skips nodes with attributes like [ExcludeFromCodeCoverage]. + /// - Simplifies logic by handling each AST type in dedicated methods. + /// + public class CoverageLocationVisitor : AstVisitor2 + { + public readonly List CoverageLocations = new(); + + public override AstVisitAction VisitScriptBlock(ScriptBlockAst scriptBlockAst) + { + if (scriptBlockAst.ParamBlock?.Attributes != null) + { + foreach (var attribute in scriptBlockAst.ParamBlock.Attributes) + { + if (attribute.TypeName.GetReflectionType() == typeof(ExcludeFromCodeCoverageAttribute)) + { + return AstVisitAction.SkipChildren; + } + } + } + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitCommand(CommandAst commandAst) + { + CoverageLocations.Add(commandAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitCommandExpression(CommandExpressionAst commandExpressionAst) + { + CoverageLocations.Add(commandExpressionAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitDynamicKeywordStatement(DynamicKeywordStatementAst dynamicKeywordStatementAst) + { + CoverageLocations.Add(dynamicKeywordStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitBreakStatement(BreakStatementAst breakStatementAst) + { + CoverageLocations.Add(breakStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitContinueStatement(ContinueStatementAst continueStatementAst) + { + CoverageLocations.Add(continueStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitExitStatement(ExitStatementAst exitStatementAst) + { + CoverageLocations.Add(exitStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitThrowStatement(ThrowStatementAst throwStatementAst) + { + CoverageLocations.Add(throwStatementAst); + return AstVisitAction.Continue; + } + + // ReturnStatementAst is excluded as it's not behaving consistent. + // "return" is not hit in 5.1 but fixed in a later version. Using "return 123" we get hit on 123 but not return. + // See https://github.com/pester/Pester/issues/1465#issuecomment-604323645 + } +} diff --git a/src/csharp/Pester/Pester.csproj b/src/csharp/Pester/Pester.csproj index 6392dbb9f..bea226a3e 100644 --- a/src/csharp/Pester/Pester.csproj +++ b/src/csharp/Pester/Pester.csproj @@ -1,7 +1,7 @@  - net6.0;net462 + net8.0;net462 latest true embedded @@ -11,14 +11,10 @@ $(DefineConstants);PESTER - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tst/Pester.RSpec.ts.ps1 b/tst/Pester.RSpec.ts.ps1 index bc400befa..2002e4d78 100644 --- a/tst/Pester.RSpec.ts.ps1 +++ b/tst/Pester.RSpec.ts.ps1 @@ -1668,6 +1668,12 @@ i -PassThru:$PassThru { } } } + + t "Does not accept unbound scriptblocks" { + # Would execute in Pester's internal module state + $ex = { New-PesterContainer -ScriptBlock ([ScriptBlock]::Create('$true')) } | Verify-Throw + $ex.Exception.Message | Verify-Like 'Unbound scriptblock*' + } } b "BeforeDiscovery" { @@ -1691,6 +1697,15 @@ i -PassThru:$PassThru { $r.Containers[0].Blocks[0].Tests[0].Result | Verify-Equal "Passed" $r.Containers[0].Blocks[1].Tests[0].Result | Verify-Equal "Passed" } + + t "Does not accept unbound scriptblocks" { + # Would execute in Pester's internal module state + $sb = { BeforeDiscovery ([ScriptBlock]::Create('$true')) } + $container = New-PesterContainer -ScriptBlock $sb + $r = Invoke-Pester -Container $container -PassThru + $r.Containers[0].Result | Verify-Equal 'Failed' + $r.Containers[0].ErrorRecord.Exception.Message | Verify-Like 'Unbound scriptblock*' + } } b "Parametric tests" { @@ -2922,4 +2937,33 @@ i -PassThru:$PassThru { $pwd.Path | Verify-Equal $beforePWD } } + + b 'Unbound scriptblocks' { + # Would execute in Pester's internal module state + t 'Throws when provided to Run.ScriptBlock' { + $sb = [scriptblock]::Create('') + $conf = New-PesterConfiguration + $conf.Run.ScriptBlock = $sb + $conf.Run.Throw = $true + $conf.Output.CIFormat = 'None' + + $ex = { Invoke-Pester -Configuration $conf } | Verify-Throw + $ex.Exception.Message | Verify-Like '*Unbound scriptblock*' + } + + t 'Throws when provided to Run.Container' { + $c = [Pester.ContainerInfo]::Create() + $c.Type = 'ScriptBlock' + $c.Item = [scriptblock]::Create('') + $c.Data = @{} + + $conf = New-PesterConfiguration + $conf.Run.Container = $c + $conf.Run.Throw = $true + $conf.Output.CIFormat = 'None' + + $ex = { Invoke-Pester -Configuration $conf } | Verify-Throw + $ex.Exception.Message | Verify-Like '*Unbound scriptblock*' + } + } } diff --git a/tst/functions/Add-ShouldOperator.ts.ps1 b/tst/functions/Add-ShouldOperator.ts.ps1 index 71c4143c9..4d5be5a08 100644 --- a/tst/functions/Add-ShouldOperator.ts.ps1 +++ b/tst/functions/Add-ShouldOperator.ts.ps1 @@ -70,6 +70,17 @@ i -PassThru:$PassThru { } } + b 'Add-ShouldOperator input validation' { + Get-Module Pester | Remove-Module + Import-Module "$PSScriptRoot\..\..\bin\Pester.psd1" + + t 'Does not allow unbound scriptblocks' { + # Would execute in Pester's internal module state + $ex = { Add-ShouldOperator -Name DenyUnbound -Test ([ScriptBlock]::Create('$true')) } | Verify-Throw + $ex.Exception.Message | Verify-Like 'Unbound scriptblock*' + } + } + b 'Executing custom Should assertions' { # Testing paramter and output syntax described in docs (https://pester.dev/docs/assertions/custom-assertions) Get-Module Pester | Remove-Module diff --git a/tst/functions/Context.Tests.ps1 b/tst/functions/Context.Tests.ps1 index 2ce94eaa7..c962c322a 100644 --- a/tst/functions/Context.Tests.ps1 +++ b/tst/functions/Context.Tests.ps1 @@ -7,7 +7,7 @@ Describe 'Testing Context' { } } - } | should -Throw 'Test fixture name has multiple lines and no test fixture is provided. (Have you provided a name for the test group?)' + } | Should -Throw 'Test fixture name has multiple lines and no test fixture is provided. (Have you provided a name for the test group?)' } It "Has a name that looks like a script block" { @@ -17,6 +17,11 @@ Describe 'Testing Context' { } } - } | should -Throw 'No test fixture is provided. (Have you put the open curly brace on the next line?)' + } | Should -Throw 'No test fixture is provided. (Have you put the open curly brace on the next line?)' + } + + It 'Throws when provided unbound scriptblock' { + # Unbound scriptblocks would execute in Pester's internal module state + { Context 'c' -Fixture ([scriptblock]::Create('')) } | Should -Throw -ExpectedMessage 'Unbound scriptblock*' } } diff --git a/tst/functions/Coverage.Tests.ps1 b/tst/functions/Coverage.Tests.ps1 index 7b87327d8..115eb7780 100644 --- a/tst/functions/Coverage.Tests.ps1 +++ b/tst/functions/Coverage.Tests.ps1 @@ -17,6 +17,8 @@ InPesterModuleScope { $testScriptPath = Join-Path -Path $root -ChildPath TestScript.ps1 $testScript2Path = Join-Path -Path $root -ChildPath TestScript2.ps1 $testScript3Path = Join-Path -Path $rootSubFolder -ChildPath TestScript3.ps1 + $testScriptStatementsPath = Join-Path -Path $root -ChildPath TestScriptStatements.ps1 + $testScriptExitPath = Join-Path -Path $root -ChildPath TestScriptExit.ps1 $null = New-Item -Path $testScriptPath -ItemType File -ErrorAction SilentlyContinue @@ -42,7 +44,7 @@ InPesterModuleScope { function FunctionTwo { - 'I am function two. I never get called.' + 'I am function two. I never get called.' } FunctionOne @@ -97,7 +99,7 @@ InPesterModuleScope { #{ function MyClass { - MyBaseClass; 'I am the constructor.' + MyBaseClass # call the base constructor } function MethodOne @@ -133,6 +135,54 @@ InPesterModuleScope { -f ` 'other' +'@ + + $null = New-Item -Path $testScriptStatementsPath -ItemType File -ErrorAction SilentlyContinue + + Set-Content -Path $testScriptStatementsPath -Value @' + try { + try { + throw 'omg' + } + catch { + throw + } + } + catch { } + + switch (1,2,3) { + 1 { continue; } + 2 { break } + 3 { 'I was skipped because 2 called break in switch.' } + } + + :myBreakLabel foreach ($i in 1..100) { + foreach ($o in 1) { + break myBreakLabel + } + 'I was skipped by a labeled break.' + } + + :myLoopLabel foreach ($i in 1) { + foreach ($o in 1..100) { + continue myLoopLabel + } + 'I was skipped by a labeled contiune.' + } + + # These should not be included in code coverage + & { return } + & { return 123 } + + # will exit the script + exit +'@ + + $null = New-Item -Path $testScriptExitPath -ItemType File -ErrorAction SilentlyContinue + + Set-Content -Path $testScriptExitPath -Value @' + # will exit the script, so keep in own file + exit 123 '@ } @@ -143,14 +193,16 @@ InPesterModuleScope { BeforeAll { # TODO: renaming, breakpoints mean "code point of interests" in most cases here, not actual breakpoints # Path deliberately duplicated to make sure the code doesn't produce multiple breakpoints for the same commands - $breakpoints = Enter-CoverageAnalysis -CodeCoverage $testScriptPath, $testScriptPath, $testScript2Path, $testScript3Path -UseBreakpoints $UseBreakpoints + $breakpoints = Enter-CoverageAnalysis -CodeCoverage $testScriptPath, $testScriptPath, $testScript2Path, $testScript3Path, $testScriptStatementsPath, $testScriptExitPath -UseBreakpoints $UseBreakpoints - @($breakpoints).Count | Should -Be 19 -Because 'it has the proper number of breakpoints defined' + @($breakpoints).Count | Should -Be 40 -Because 'it has the proper number of breakpoints defined' $sb = { $null = & $testScriptPath $null = & $testScript2Path $null = & $testScript3Path + $null = & $testScriptStatementsPath + $null = & $testScriptExitPath } if ($UseBreakpoints) { @@ -167,29 +219,32 @@ InPesterModuleScope { } It 'Reports the proper number of executed commands' { - $coverageReport.NumberOfCommandsExecuted | Should -Be 16 + $coverageReport.NumberOfCommandsExecuted | Should -Be 34 } It 'Reports the proper number of analyzed commands' { - $coverageReport.NumberOfCommandsAnalyzed | Should -Be 19 + $coverageReport.NumberOfCommandsAnalyzed | Should -Be 40 } It 'Reports the proper number of analyzed files' { - $coverageReport.NumberOfFilesAnalyzed | Should -Be 3 + $coverageReport.NumberOfFilesAnalyzed | Should -Be 5 } It 'Reports the proper number of missed commands' { - $coverageReport.MissedCommands.Count | Should -Be 3 + $coverageReport.MissedCommands.Count | Should -Be 6 } It 'Reports the correct missed command' { $coverageReport.MissedCommands[0].Command | Should -Be "'I cannot get called.'" - $coverageReport.MissedCommands[1].Command | Should -Be "'I am function two. I never get called.'" + $coverageReport.MissedCommands[1].Command | Should -Be "'I am function two. I never get called.'" $coverageReport.MissedCommands[2].Command | Should -Be "'I am method two. I never get called.'" + $coverageReport.MissedCommands[3].Command | Should -Be "'I was skipped because 2 called break in switch.'" + $coverageReport.MissedCommands[4].Command | Should -Be "'I was skipped by a labeled break.'" + $coverageReport.MissedCommands[5].Command | Should -Be "'I was skipped by a labeled contiune.'" } It 'Reports the proper number of hit commands' { - $coverageReport.HitCommands.Count | Should -Be 16 + $coverageReport.HitCommands.Count | Should -Be 34 } It 'Reports the correct hit command' { @@ -289,6 +344,28 @@ InPesterModuleScope { + + + + + + + + + + + + + + + + + + + + + + @@ -317,10 +394,40 @@ InPesterModuleScope { - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + ') } @@ -429,6 +536,28 @@ InPesterModuleScope { + + + + + + + + + + + + + + + + + + + + + + @@ -457,10 +586,40 @@ InPesterModuleScope { - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -486,14 +645,148 @@ InPesterModuleScope { - - - - + + + + ') } + It 'Cobertura report must be correct' { + [String]$coberturaReportXml = Get-CoberturaReportXml -TotalMilliseconds 10000 -CoverageReport $coverageReport + $coberturaReportXml = $coberturaReportXml -replace 'timestamp="[0-9]*"', 'timestamp=""' + $coberturaReportXml = $coberturaReportXml -replace "$([System.Environment]::NewLine)", '' + $coberturaReportXml = $coberturaReportXml.Replace($root, 'CommonRoot') + $coberturaReportXml = $coberturaReportXml.Replace($root.Replace('\', '/'), 'CommonRoot') + (Clear-WhiteSpace $coberturaReportXml) | Should -Be (Clear-WhiteSpace ' + + + + + CommonRoot + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ') + } + It 'JaCoCo returns empty string when there are 0 analyzed commands' { $coverageReport = [PSCustomObject] @{ NumberOfCommandsAnalyzed = 0 } [String]$jaCoCoReportXml = Get-JaCoCoReportXml -CommandCoverage @{} -TotalMilliseconds 10000 -CoverageReport $coverageReport -Format "CoverageGutters" @@ -501,6 +794,13 @@ InPesterModuleScope { $jaCoCoReportXml | Should -Be ([String]::Empty) } + It 'Cobertura returns empty string when there are 0 analyzed commands' { + $coverageReport = [PSCustomObject] @{ NumberOfCommandsAnalyzed = 0 } + [String]$coberturaReportXml = Get-CoberturaReportXml -CoverageReport $coverageReport -TotalMilliseconds 10000 + $coberturaReportXml | Should -Not -Be $null + $coberturaReportXml | Should -Be ([String]::Empty) + } + It 'Reports the right line numbers' { $coverageReport.HitCommands[$coverageReport.NumberOfCommandsExecuted - 1].Line | Should -Be 1 $coverageReport.HitCommands[$coverageReport.NumberOfCommandsExecuted - 1].StartLine | Should -Be 1 @@ -552,7 +852,7 @@ InPesterModuleScope { } It 'Reports the correct missed command' { - $coverageReport.MissedCommands[0].Command | Should -Be "'I am function two. I never get called.'" + $coverageReport.MissedCommands[0].Command | Should -Be "'I am function two. I never get called.'" } It 'Reports the proper number of hit commands' { @@ -703,7 +1003,7 @@ InPesterModuleScope { It 'Reports the correct missed command' { $coverageReport.MissedCommands[0].Command | Should -Be "'I cannot get called.'" - $coverageReport.MissedCommands[1].Command | Should -Be "'I am function two. I never get called.'" + $coverageReport.MissedCommands[1].Command | Should -Be "'I am function two. I never get called.'" } It 'Reports the proper number of hit commands' { @@ -984,6 +1284,131 @@ InPesterModuleScope { } } + Describe 'Coverage Location Visitor' { + BeforeAll { + $testScript = @' + using namespace System.Diagnostics.CodeAnalysis + + function FunctionIncluded { + "I am included" + } + + function FunctionExcluded { + [ExcludeFromCodeCoverageAttribute()] + param() + + "I am not included" + } + + FunctionIncluded + FunctionExcluded + +'@ + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($testScript, [ref]$tokens, [ref]$errors) + } + + Context 'Collect coverage locations' { + BeforeAll { + $visitor = [Pester.CoverageLocationVisitor]::new() + $ast.Visit($visitor) + } + + It 'Skips excluded script blocks' { + $excludedCommand = $visitor.CoverageLocations | Where-Object { + $_ -is [System.Management.Automation.Language.CommandExpressionAst] -and + $_.Expression.Value -eq "I am not included" + } + + $excludedCommand.Count | Should -Be 0 -Because "Command in excluded script blocks should not be collected." + } + + It 'Processes included script blocks' { + $includedCommand = $visitor.CoverageLocations | Where-Object { + $_ -is [System.Management.Automation.Language.CommandExpressionAst] -and + $_.Expression.Value -eq "I am included" + } + + $includedCommand.Count | Should -Be 1 -Because "Command in included script blocks should be collected." + } + } + + Context 'Collect coverage locations for other AST types' { + It 'Collects all relevant AST types' { + $script = @' + foreach ($i in 1..10) { # 1 location + break # 1 location + continue # 1 location + if ($i -eq 5) { # 1 location + throw # 1 location + } + if ($i -eq 7) { # 1 location + exit # 1 location + } + return # not collected + } +'@ + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($script, [ref]$tokens, [ref]$errors) + + $visitor = [Pester.CoverageLocationVisitor]::new() + $ast.Visit($visitor) + + $visitor.CoverageLocations.Count | Should -Be 7 -Because "Break, Continue, Throw, and Exit statements should be collected." + } + } + + Context 'Coverage analysis with exclusion using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "With breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler-based coverage collection" } + ) { + BeforeAll { + $root = (Get-PSDrive TestDrive).Root + $testScriptPath = Join-Path -Path $root -ChildPath TestScript.ps1 + Set-Content -Path $testScriptPath -Value $testScript + + $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{ Path = $testScriptPath } -UseBreakpoints $UseBreakpoints + + @($breakpoints).Count | Should -Be 3 -Because 'The correct number of breakpoints should be defined.' + + if ($UseBreakpoints) { + & $testScriptPath + } + else { + $patched, $tracer = Start-TraceScript $breakpoints + try { & $testScriptPath } finally { Stop-TraceScript -Patched $patched } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure + } + + It 'Correctly reports executed commands' { + $coverageReport.NumberOfCommandsExecuted | Should -Be 3 -Because 'The executed commands count should match.' + } + + It 'Correctly reports analyzed commands' { + $coverageReport.NumberOfCommandsAnalyzed | Should -Be 3 -Because 'All commands should be analyzed.' + } + + It 'Correctly reports missed commands' { + $coverageReport.MissedCommands.Count | Should -Be 0 -Because 'No command should be missed.' + } + + It 'Correctly reports hit commands' { + $coverageReport.HitCommands.Count | Should -Be 3 -Because 'Three commands should be hit.' + } + + AfterAll { + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } + } + } + } + # Describe 'Stripping common parent paths' { # If ( (& $SafeCommands['Get-Variable'] -Name IsLinux -Scope Global -ErrorAction SilentlyContinue) -or diff --git a/tst/functions/Describe.Tests.ps1 b/tst/functions/Describe.Tests.ps1 index 0ade9f093..fa292512e 100644 --- a/tst/functions/Describe.Tests.ps1 +++ b/tst/functions/Describe.Tests.ps1 @@ -36,6 +36,11 @@ Describe 'Testing Describe' { } } | Should -Throw 'Test fixture name has multiple lines and no test fixture is provided. (Have you provided a name for the test group?)' } + + It 'Throws when provided unbound scriptblock' { + # Unbound scriptblocks would execute in Pester's internal module state + { Describe 'd' -Fixture ([scriptblock]::Create('')) } | Should -Throw -ExpectedMessage 'Unbound scriptblock*' + } } diff --git a/tst/functions/It.Tests.ps1 b/tst/functions/It.Tests.ps1 new file mode 100644 index 000000000..6249f10af --- /dev/null +++ b/tst/functions/It.Tests.ps1 @@ -0,0 +1,24 @@ +Set-StrictMode -Version Latest + +Describe 'Testing It' { + It 'Throws when missing name' { + { It { + + 'something' + } + } | Should -Throw -ExpectedMessage 'Test name has multiple lines and no test scriptblock is provided*' + } + + It 'Throws when missing scriptblock' { + { It 'runs a test' + { + # This scriptblock is a new statement as scriptblock didn't start on It-line nor used a backtick + } + } | Should -Throw -ExpectedMessage 'No test scriptblock is provided*' + } + + It 'Throws when provided unbound scriptblock' { + # Unbound scriptblocks would execute in Pester's internal module state + { It 'i' -Test ([scriptblock]::Create('')) } | Should -Throw -ExpectedMessage 'Unbound scriptblock*' + } +} diff --git a/tst/functions/Mock.Tests.ps1 b/tst/functions/Mock.Tests.ps1 index ab769e95a..575b8ee56 100644 --- a/tst/functions/Mock.Tests.ps1 +++ b/tst/functions/Mock.Tests.ps1 @@ -2366,15 +2366,25 @@ Describe 'Naming conflicts in mocked functions' { } Describe 'Passing unbound script blocks as mocks' { - It 'Does not produce an error' { + BeforeAll { function TestMe { 'Original' } + } + It 'Does not produce an error' { $scriptBlock = [scriptblock]::Create('"Mocked"') { Mock TestMe $scriptBlock } | Should -Not -Throw TestMe | Should -Be Mocked } + + It 'Should not execute in Pester internal state' { + $filter = [scriptblock]::Create('if ("pester" -eq $ExecutionContext.SessionState.Module) { throw "executed parameter filter in internal state" } else { $true }') + $scriptBlock = [scriptblock]::Create('if ("pester" -eq $ExecutionContext.SessionState.Module) { throw "executed mock in internal state" } else { "Mocked" }') + + { Mock -CommandName TestMe -ParameterFilter $filter -MockWith $scriptBlock } | Should -Not -Throw + TestMe -SomeParam | Should -Be Mocked + } } Describe 'Should -Invoke when mock called outside of It block' { @@ -3150,3 +3160,66 @@ Describe 'Mocking with nested Pester runs' { Get-Command Get-ChildItem | Should -Not -Be 2 } } + +Describe 'Usage of Alias in DynamicParams' { + # https://github.com/pester/Pester/issues/1274 + + BeforeAll { + function New-DynamicAttr($ParamDictionary, $Name, $Alias = $null) { + $attr = New-Object -Type System.Management.Automation.ParameterAttribute + $attr.Mandatory = $false + $attr.ParameterSetName = '__AllParameterSets' + $attributeCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute] + $attributeCollection.Add($attr) + + if ($null -ne $Alias) { + $attr = New-Object -Type System.Management.Automation.AliasAttribute -ArgumentList @($Alias) + $attributeCollection.Add($attr) + } + + $dynParam1 = New-Object -Type System.Management.Automation.RuntimeDefinedParameter($Name, [string], $attributeCollection) + + $ParamDictionary.Add($Name, $dynParam1) + } + + function Test-DynamicParam { + [CmdletBinding()] + param( + [String]$Name + ) + + dynamicparam { + if ($Name.StartsWith("Hello")) { + $paramDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary + New-DynamicAttr -ParamDictionary $paramDictionary -Name "PSEdition" + + return $paramDictionary + } + } + + process { + if ($PSBoundParameters.PSEdition) { + Write-Host "PSEdition value: $($PSBoundParameters.PSEdition)" + } + } + } + } + + Context 'Mocking with ParameterFilter' { + It 'Mocks Test-DynamicParam with PSEdition set to Desktop' { + Mock Test-DynamicParam { "World" } -ParameterFilter { $_PSEdition -eq 'Desktop' } + + Test-DynamicParam -Name "Hello" -PSEdition 'Desktop' | Should -Be 'World' + } + } + + Context 'Validating Mock Invocation' { + It 'Invokes Test-DynamicParam with correct parameters' { + Mock Test-DynamicParam { "World" } + + Test-DynamicParam -Name "Hello" -PSEdition 'Desktop' | Should -Be 'World' + + Should -Invoke Test-DynamicParam -Exactly 1 -Scope It + } + } +} diff --git a/tst/functions/Set-ItResult.Tests.ps1 b/tst/functions/Set-ItResult.Tests.ps1 index ed7f642a9..91622e4be 100644 --- a/tst/functions/Set-ItResult.Tests.ps1 +++ b/tst/functions/Set-ItResult.Tests.ps1 @@ -19,6 +19,15 @@ Describe "Testing Set-ItResult" { } } + It "Set-ItResult appends the -Because reason to the message" { + try { + Set-ItResult -Skipped -Because "we are forcing it to skip" + } + catch { + $_.Exception.Message | Should -Be "is skipped, because we are forcing it to skip" + } + } + It "Set-ItResult can be called without -Because" { try { Set-ItResult -Skipped diff --git a/tst/functions/SetupTeardown.Tests.ps1 b/tst/functions/SetupTeardown.Tests.ps1 index 864ffa0e2..8b0f6236f 100644 --- a/tst/functions/SetupTeardown.Tests.ps1 +++ b/tst/functions/SetupTeardown.Tests.ps1 @@ -190,6 +190,25 @@ Describe 'Finishing TestGroup Setup and Teardown tests' { } } +Describe 'Unbound scriptsblocks as input' { + # Unbound scriptblocks would execute in Pester's internal module state + BeforeAll { + $sb = [scriptblock]::Create('') + $expectedMessage = 'Unbound scriptblock*' + } + It 'Throws when provided to BeforeAll' { + { BeforeAll -Scriptblock $sb } | Should -Throw -ExpectedMessage $expectedMessage + } + It 'Throws when provided to AfterAll' { + { AfterAll -Scriptblock $sb } | Should -Throw -ExpectedMessage $expectedMessage + } + It 'Throws when provided to BeforeEach' { + { BeforeEach -Scriptblock $sb } | Should -Throw -ExpectedMessage $expectedMessage + } + It 'Throws when provided to AfterEach' { + { AfterEach -Scriptblock $sb } | Should -Throw -ExpectedMessage $expectedMessage + } +} # if ($PSVersionTable.PSVersion.Major -ge 3) { # # TODO: this depends on the old pester internals it would be easier to test in P diff --git a/tst/functions/assert/Collection/Should-All.Tests.ps1 b/tst/functions/assert/Collection/Should-All.Tests.ps1 index b02c7efca..51e431664 100644 --- a/tst/functions/assert/Collection/Should-All.Tests.ps1 +++ b/tst/functions/assert/Collection/Should-All.Tests.ps1 @@ -57,4 +57,10 @@ Expected [int] 2, but got [int] 1." -replace "`r`n", "`n") It 'It fails when the only item not matching the filter is 0' { { 0 | Should-All -FilterScript { $_ -gt 0 } } | Verify-AssertionFailed } + + It 'Throws when provided unbound scriptblock' { + # Unbound scriptblocks would execute in Pester's internal module state + $ex = { 1 | Should-All ([scriptblock]::Create('')) } | Verify-Throw + $ex.Exception.Message | Verify-Like 'Unbound scriptblock*' + } } diff --git a/tst/functions/assert/Collection/Should-Any.Tests.ps1 b/tst/functions/assert/Collection/Should-Any.Tests.ps1 index 03fe6fd9f..bfd5f1279 100644 --- a/tst/functions/assert/Collection/Should-Any.Tests.ps1 +++ b/tst/functions/assert/Collection/Should-Any.Tests.ps1 @@ -61,4 +61,10 @@ Expected [int] 2, but got [int] 1." -replace "`r`n", "`n") It "Accepts FilterScript and Actual by position" { Should-Any { $true } 1, 2 } + + It 'Throws when provided unbound scriptblock' { + # Unbound scriptblocks would execute in Pester's internal module state + $ex = { 1 | Should-Any ([scriptblock]::Create('')) } | Verify-Throw + $ex.Exception.Message | Verify-Like 'Unbound scriptblock*' + } } diff --git a/tst/functions/assert/Collection/Should-BeCollection.Tests.ps1 b/tst/functions/assert/Collection/Should-BeCollection.Tests.ps1 index ef5dab0de..7038df09c 100644 --- a/tst/functions/assert/Collection/Should-BeCollection.Tests.ps1 +++ b/tst/functions/assert/Collection/Should-BeCollection.Tests.ps1 @@ -24,4 +24,26 @@ Describe "Should-BeCollection" { $err = { $actual | Should-BeCollection $expected } | Verify-AssertionFailed $err.Exception.Message | Verify-Equal "Expected [Object[]] @(5, 6, 7, 8, 9) to be present in [Object[]] @(1, 2, 3, 4, 5) in any order, but some values were not.`nMissing in actual: '6 (index 1), 7 (index 2), 8 (index 3), 9 (index 4)'`nExtra in actual: '1 (index 0), 2 (index 1), 3 (index 2), 4 (index 3)'" } + + Describe "-Count" { + It "Counts empty collection @() correctly" { + @() | Should-BeCollection -Count 0 + } + + It "Counts collection with one item correctly" -ForEach @( + @(1), + (, @()), # array in array + @($null), + @(""), + # we also cannot distinguish between a single item and a single item array + 1 + ) { + $_ | Should-BeCollection -Count 1 + } + + It "Fails when collection does not have the expected number of items" { + $err = { @(1, 2) | Should-BeCollection -Count 3 } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected 3 items in [Object[]] @(1, 2), but it has 2 items." + } + } } diff --git a/tst/functions/assert/Exception/Should-Throw.Tests.ps1 b/tst/functions/assert/Exception/Should-Throw.Tests.ps1 index 4c2cc6084..cf0c516a9 100644 --- a/tst/functions/assert/Exception/Should-Throw.Tests.ps1 +++ b/tst/functions/assert/Exception/Should-Throw.Tests.ps1 @@ -10,7 +10,6 @@ Describe "Should-Throw" { } It "Passes when non-terminating exception is thrown" { - { Write-Error "fail!" } | Should-Throw } @@ -23,6 +22,12 @@ Describe "Should-Throw" { Should-Throw 'MockErrorMessage' 'MockErrorId' ([Microsoft.PowerShell.Commands.WriteErrorException]) 'MockBecauseString' } + It 'Throws when provided unbound scriptblock' { + # Unbound scriptblocks would execute in Pester's internal module state + $ex = { ([scriptblock]::Create('')) | Should-Throw } | Verify-Throw + $ex.Exception.Message | Verify-Like 'Unbound scriptblock*' + } + Context "Filtering with exception type" { It "Passes when exception has the expected type" { { throw [ArgumentException]"A is null!" } | Should-Throw -ExceptionType ([ArgumentException])