diff --git a/CHANGELOG.md b/CHANGELOG.md index de28545..fc53d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Fixed + +- [**#102**](https://github.com/psake/PowerShellBuild/issues/102) + `Test-PSBuildPester` no longer raises a parameter-binding error from its + cleanup logic when the optional `ModuleName` parameter is not supplied. +- [**#102**](https://github.com/psake/PowerShellBuild/issues/102) + `Test-PSBuildPester` now respects a Pester module that is already loaded in + the session instead of unconditionally importing the newest installed + version on top of it, which crashed with a Pester.dll version conflict when + two Pester versions were installed side by side. When no Pester is loaded, + the newest installed version (5.0.0 minimum) is imported as before, and a + loaded Pester older than 5.0.0 now produces a clear error. + ## [0.8.2] 2026-07-08 ### Fixed diff --git a/PowerShellBuild/PowerShellBuild.psm1 b/PowerShellBuild/PowerShellBuild.psm1 index 0d4e7fa..d6ebe19 100644 --- a/PowerShellBuild/PowerShellBuild.psm1 +++ b/PowerShellBuild/PowerShellBuild.psm1 @@ -27,6 +27,7 @@ FolderDoesNotExist=Folder does not exist: {0} PathArgumentMustBeAFolder=The Path argument must be a folder. File paths are not allowed. UnableToFindModuleManifest=Unable to find module manifest [{0}]. Can't import module PesterTestsFailed=One or more Pester tests failed +PesterVersionNotSupported=Pester version [{0}] is loaded, but Test-PSBuildPester requires Pester 5.0.0 or newer. CodeCoverage=Code Coverage Type=Type CodeCoverageLessThanThreshold=Code coverage: [{0}] is [{1:p}], which is less than the threshold of [{2:p}] diff --git a/PowerShellBuild/Public/Test-PSBuildPester.ps1 b/PowerShellBuild/Public/Test-PSBuildPester.ps1 index 5aba62c..156d837 100644 --- a/PowerShellBuild/Public/Test-PSBuildPester.ps1 +++ b/PowerShellBuild/Public/Test-PSBuildPester.ps1 @@ -67,8 +67,15 @@ function Test-PSBuildPester { [string]$OutputVerbosity = 'Detailed' ) - if (-not (Get-Module -Name Pester)) { - Import-Module -Name Pester -ErrorAction Stop + # Respect an already-loaded Pester so callers can pin a specific version; importing again + # would load the newest installed Pester on top of it, which crashes when two Pester + # versions are installed side by side. Only load Pester ourselves when none is loaded. + $loadedPester = Get-Module -Name Pester + if (-not $loadedPester) { + $loadedPester = Import-Module -Name Pester -MinimumVersion 5.0.0 -ErrorAction Stop -PassThru + } + if ($loadedPester.Version -lt [version]'5.0.0') { + throw ($LocalizedData.PesterVersionNotSupported -f $loadedPester.Version) } try { @@ -84,7 +91,6 @@ function Test-PSBuildPester { Push-Location -LiteralPath $Path - Import-Module Pester -MinimumVersion 5.0.0 $configuration = [PesterConfiguration]::Default $configuration.Output.Verbosity = $OutputVerbosity $configuration.Run.PassThru = $true @@ -142,6 +148,10 @@ function Test-PSBuildPester { } } finally { Pop-Location - Remove-Module $ModuleName -ErrorAction SilentlyContinue + # ModuleName is optional; Remove-Module with an empty -Name raises a parameter-binding + # error that -ErrorAction SilentlyContinue cannot suppress. + if ($ModuleName) { + Remove-Module -Name $ModuleName -ErrorAction SilentlyContinue + } } } diff --git a/PowerShellBuild/en-US/Messages.psd1 b/PowerShellBuild/en-US/Messages.psd1 index 58aff5e..22f370d 100644 --- a/PowerShellBuild/en-US/Messages.psd1 +++ b/PowerShellBuild/en-US/Messages.psd1 @@ -14,6 +14,7 @@ FolderDoesNotExist=Folder does not exist: {0} PathArgumentMustBeAFolder=The Path argument must be a folder. File paths are not allowed. UnableToFindModuleManifest=Unable to find module manifest [{0}]. Can't import module PesterTestsFailed=One or more Pester tests failed +PesterVersionNotSupported=Pester version [{0}] is loaded, but Test-PSBuildPester requires Pester 5.0.0 or newer. CodeCoverage=Code Coverage Type=Type CodeCoverageLessThanThreshold=Code coverage: [{0}] is [{1:p}], which is less than the threshold of [{2:p}] diff --git a/build.ps1 b/build.ps1 index 8529de6..4237efb 100644 --- a/build.ps1 +++ b/build.ps1 @@ -42,6 +42,9 @@ if ($Bootstrap.IsPresent) { } Import-Module -Name PSDepend -Verbose:$false Invoke-PSDepend -Path './requirements.psd1' -Install -Import -Force -WarningAction SilentlyContinue + # Install-only, never imported: importing a second Pester major into this session would + # crash with a Pester.dll version conflict. See requirements.pester-matrix.psd1. + Invoke-PSDepend -Path './requirements.pester-matrix.psd1' -Install -Force -WarningAction SilentlyContinue } # Execute psake task(s) diff --git a/requirements.pester-matrix.psd1 b/requirements.pester-matrix.psd1 new file mode 100644 index 0000000..cd58532 --- /dev/null +++ b/requirements.pester-matrix.psd1 @@ -0,0 +1,18 @@ +# Install-only dependencies. The bootstrap in build.ps1 installs this file WITHOUT importing: +# these modules exist so the Test-PSBuildPester integration tests can pin them inside +# subprocesses, and importing a second Pester major into the bootstrap session would crash +# with a Pester.dll version conflict against the Pester version from requirements.psd1. +@{ + PSDependOptions = @{ + Target = 'CurrentUser' + } + # Newest Pester 5.x, installed side by side with the pinned 6.x so the shipped + # Test-PSBuildPester function is verified against both supported majors. + PesterLegacy = @{ + Name = 'Pester' + Version = '5.9.0' + Parameters = @{ + SkipPublisherCheck = $true + } + } +} diff --git a/tests/Test-PSBuildPester.tests.ps1 b/tests/Test-PSBuildPester.tests.ps1 new file mode 100644 index 0000000..e3cc9f7 --- /dev/null +++ b/tests/Test-PSBuildPester.tests.ps1 @@ -0,0 +1,256 @@ +# Integration tests for Test-PSBuildPester (psake/PowerShellBuild#102). +# +# Test-PSBuildPester wraps Invoke-Pester, so these tests are Pester-testing-Pester. Every +# invocation runs in a Start-Job subprocess: two Pester versions cannot coexist in one session, +# and the subprocess lets each test pin the inner Pester version independently of the outer +# framework. The scenarios run against every installed Pester major (5.x and 6.x) to verify the +# shipped function keeps supporting Pester 5 consumers. +# +# The crash fixtures are generated into $TestDrive at runtime, never checked in, so the +# repository's own Pester run can never discover them (see #97 for the convention). + +BeforeDiscovery { + # Newest installed Pester of each supported major version. CI installs 6.x (Pester) and + # 5.x (PesterLegacy) via requirements.psd1; locally, absent majors simply produce fewer + # matrix legs. + $script:innerPesterVersions = @( + foreach ($majorVersion in 5, 6) { + $newestOfMajor = Get-Module -Name 'Pester' -ListAvailable | + Where-Object { $_.Version.Major -eq $majorVersion } | + Sort-Object -Property 'Version' -Descending | + Select-Object -First 1 + if ($newestOfMajor) { + $newestOfMajor.Version.ToString() + } + } + ) +} + +Describe 'Test-PSBuildPester' { + + BeforeAll { + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + $script:builtModulePath = [IO.Path]::Combine($script:moduleRoot, 'Output', 'PowerShellBuild') + + Import-Module -Name ([IO.Path]::Combine($PSScriptRoot, 'fixtures', 'FixtureHelpers.psm1')) -Force + + # Runs Test-PSBuildPester in a subprocess with a pinned inner Pester version and reports + # what happened. Returns an object with Threw, ErrorMessage, and the Pester version that + # was loaded in the subprocess after the call. + function script:Invoke-TestPSBuildPesterJob { + param( + [string]$InnerPesterVersion, + [string]$Path, + [hashtable]$AdditionalParameters = @{} + ) + + $job = Start-Job -ScriptBlock { + param($innerPesterVersion, $builtModulePath, $path, $additionalParameters) + + Import-Module -Name 'Pester' -RequiredVersion $innerPesterVersion -ErrorAction Stop + Import-Module -Name $builtModulePath -Force -ErrorAction Stop + + $testPSBuildPesterParameters = @{ + Path = $path + OutputVerbosity = 'None' + ErrorAction = 'Stop' + } + foreach ($key in $additionalParameters.Keys) { + $testPSBuildPesterParameters[$key] = $additionalParameters[$key] + } + + $threw = $false + $errorMessage = $null + try { + Test-PSBuildPester @testPSBuildPesterParameters + } catch { + $threw = $true + $errorMessage = $_.Exception.Message + } + + [PSCustomObject]@{ + Threw = $threw + ErrorMessage = $errorMessage + LoadedPesterVersions = @((Get-Module -Name 'Pester').Version.ToString()) + } + } -ArgumentList $InnerPesterVersion, $script:builtModulePath, $Path, $AdditionalParameters + + $jobResult = $job | Wait-Job | Receive-Job + Remove-Job -Job $job -Force + $jobResult + } + + # Scenario directories, generated at runtime. + $script:healthyPath = Join-Path -Path $TestDrive -ChildPath 'healthy' + $script:failingTestPath = Join-Path -Path $TestDrive -ChildPath 'failingtest' + $script:beforeAllCrashPath = Join-Path -Path $TestDrive -ChildPath 'beforeallcrash' + $script:discoveryCrashPath = Join-Path -Path $TestDrive -ChildPath 'discoverycrash' + $script:coveragePath = Join-Path -Path $TestDrive -ChildPath 'coverage' + $script:outputPath = Join-Path -Path $TestDrive -ChildPath 'out' + foreach ($directory in @( + $script:healthyPath + $script:failingTestPath + $script:beforeAllCrashPath + $script:discoveryCrashPath + $script:coveragePath + $script:outputPath + )) { + New-Item -Path $directory -ItemType Directory -Force > $null + } + + Set-Content -Path (Join-Path -Path $script:healthyPath -ChildPath 'Healthy.tests.ps1') -Value @' +Describe 'Healthy suite' { + It 'passes' { + 1 | Should -Be 1 + } +} +'@ + + Set-Content -Path (Join-Path -Path $script:failingTestPath -ChildPath 'FailingTest.tests.ps1') -Value @' +Describe 'Suite with a failing test' { + It 'fails' { + 1 | Should -Be 2 + } +} +'@ + + Set-Content -Path (Join-Path -Path $script:beforeAllCrashPath -ChildPath 'BeforeAllCrash.tests.ps1') -Value @' +Describe 'Suite with a broken setup' { + BeforeAll { + throw 'BeforeAll exploded' + } + + It 'never executes' { + 1 | Should -Be 1 + } +} +'@ + + Set-Content -Path (Join-Path -Path $script:discoveryCrashPath -ChildPath 'DiscoveryCrash.tests.ps1') -Value @' +throw 'file exploded during discovery' + +Describe 'Unreachable suite' { + It 'is never discovered' { + 1 | Should -Be 1 + } +} +'@ + + # Coverage scenario: tests exercising the fixture module, with coverage measured on the + # fixture's public functions. + $script:fixturePath = Copy-PSBuildTestFixture -Destination $TestDrive + $fixtureManifestPath = Join-Path -Path $script:fixturePath -ChildPath 'PSBuildTestFixture.psd1' + Set-Content -Path (Join-Path -Path $script:coveragePath -ChildPath 'Coverage.tests.ps1') -Value @" +BeforeAll { + Import-Module -Name '$fixtureManifestPath' -Force +} + +Describe 'Coverage target' { + It 'calls Get-Widget' { + (Get-Widget -Name 'Sprocket').Name | Should -Be 'Sprocket' + } +} +"@ + } + + AfterAll { + Remove-Module -Name 'FixtureHelpers' -Force -ErrorAction SilentlyContinue + } + + Context 'with inner Pester <_>' -ForEach $script:innerPesterVersions { + + BeforeAll { + $script:innerVersion = $_ + } + + It 'succeeds for a healthy suite' { + $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:healthyPath + + $result.Threw | Should -BeFalse + } + + It 'fails the build when a test fails' { + # Regression: #52 + $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:failingTestPath + + $result.Threw | Should -BeTrue + $result.ErrorMessage | Should -Match 'Pester tests failed' + } + + It 'fails the build when a setup block throws' { + # Regression: #128 / #133 (FailedCount alone misses failed blocks) + $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:beforeAllCrashPath + + $result.Threw | Should -BeTrue + $result.ErrorMessage | Should -Match 'Pester tests failed' + } + + It 'fails the build when a test file errors during discovery' { + # Regression: #128 / #133 (FailedCount alone misses failed containers) + $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:discoveryCrashPath + + $result.Threw | Should -BeTrue + $result.ErrorMessage | Should -Match 'Pester tests failed' + } + + It 'writes test results to the requested output path' { + $testResultsPath = Join-Path -Path $script:outputPath -ChildPath "testResults-$script:innerVersion.xml" + $additionalParameters = @{ + OutputPath = $testResultsPath + } + $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:healthyPath -AdditionalParameters $additionalParameters + + $result.Threw | Should -BeFalse + $testResultsPath | Should -Exist + } + + It 'writes code coverage in the requested format to the requested path' { + # Regression: #62 + $coverageOutputPath = Join-Path -Path $script:outputPath -ChildPath "coverage-$script:innerVersion.xml" + $additionalParameters = @{ + CodeCoverage = $true + CodeCoverageFiles = @(Join-Path -Path $script:fixturePath -ChildPath 'Public/*.ps1') + CodeCoverageOutputFile = $coverageOutputPath + CodeCoverageOutputFileFormat = 'JaCoCo' + } + $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:coveragePath -AdditionalParameters $additionalParameters + + $result.Threw | Should -BeFalse + $coverageOutputPath | Should -Exist + [xml]$coverageReport = Get-Content -Path $coverageOutputPath -Raw + $coverageReport.report | Should -Not -BeNullOrEmpty + } + } + + # BeforeDiscovery variables are not visible during the run phase, so the discovered version + # list is handed to the run phase through -ForEach. + Context 'Regressions independent of the inner Pester version' -ForEach @( + @{ AvailableVersions = $script:innerPesterVersions } + ) { + + BeforeAll { + $script:newestInnerVersion = $AvailableVersions | Select-Object -Last 1 + $script:oldestInnerVersion = $AvailableVersions | Select-Object -First 1 + } + + It 'does not error when ModuleName is not provided' { + # Regression: the finally block called Remove-Module with an empty -Name, which + # raised a parameter-binding error that -ErrorAction SilentlyContinue cannot + # suppress. + $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:newestInnerVersion -Path $script:healthyPath + + $result.Threw | Should -BeFalse + $result.ErrorMessage | Should -BeNullOrEmpty + } + + It 'honors the Pester version that is already loaded' -Skip:($script:innerPesterVersions.Count -lt 2) { + # Regression: an unconditional Import-Module Pester -MinimumVersion 5.0.0 loaded the + # newest installed Pester on top of an already-loaded older one, which crashes with a + # Pester.dll version conflict when 5.x and 6.x are installed side by side. + $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:oldestInnerVersion -Path $script:healthyPath + + $result.Threw | Should -BeFalse + $result.LoadedPesterVersions | Should -Be @($script:oldestInnerVersion) + } + } +}