From f6a0f4876aaee05ac7a2c9b6d0a16cc72d7af41a Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 24 Aug 2026 11:31:59 -0400 Subject: [PATCH 1/5] chore: Install PlatyPS 1.x alongside platyPS and baseline the help build Part of #105, PlatyPS step 2a. Nothing consumer-facing changes. Microsoft.PowerShell.PlatyPS 1.0.3 goes into an install-only requirements file rather than requirements.psd1, because build.ps1 bootstraps that file with -Import. The two PlatyPS modules cannot both be imported into one session: each loads its own YamlDotNet.dll through NestedModules with a different assembly identity, so whichever imports second fails with "Assembly with same name is already loaded". The failure is symmetric -- neither order works on PowerShell 7 -- and only a separate process escapes it. requirements.pester-matrix.psd1 already existed for the same shape of problem with the Pester majors, so it is renamed to the general requirements.install-only.psd1 rather than adding a second bespoke file. The three Build-PSBuild*Help functions had no tests, and the repository does not run its own docs tasks, so nothing observed them at all. The new baseline covers them against current platyPS 0.14.2 behavior, giving the migrations in 2b/2c/2d something to regress against. Each invocation runs in a subprocess, which the assembly conflict makes mandatory once the old and new implementations coexist. Writing the baseline surfaced #169: Build-PSBuildUpdatableHelp cannot succeed as wired. Its assertions are written here and skipped with a pointer, so they are not written twice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012AKaM9i6NyMFDcJNeC34h5 --- build.ps1 | 6 +- requirements.install-only.psd1 | 37 ++++ requirements.pester-matrix.psd1 | 18 -- tests/Build-PSBuildHelp.tests.ps1 | 276 ++++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 21 deletions(-) create mode 100644 requirements.install-only.psd1 delete mode 100644 requirements.pester-matrix.psd1 create mode 100644 tests/Build-PSBuildHelp.tests.ps1 diff --git a/build.ps1 b/build.ps1 index 4237efb..4b0d731 100644 --- a/build.ps1 +++ b/build.ps1 @@ -42,9 +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 + # Install-only, never imported: every module in this file conflicts with something + # requirements.psd1 imports. See requirements.install-only.psd1 for the per-module reason. + Invoke-PSDepend -Path './requirements.install-only.psd1' -Install -Force -WarningAction SilentlyContinue } # Execute psake task(s) diff --git a/requirements.install-only.psd1 b/requirements.install-only.psd1 new file mode 100644 index 0000000..5bd0adf --- /dev/null +++ b/requirements.install-only.psd1 @@ -0,0 +1,37 @@ +# Install-only dependencies. The bootstrap in build.ps1 installs this file WITHOUT importing. +# +# Every entry here exists because importing it into the bootstrap session would break that +# session: two majors of the same module, or two modules that ship conflicting copies of the +# same assembly. They are installed so that tests and build tasks can load them deliberately, +# in a subprocess or at the point of use, rather than implicitly at bootstrap. +@{ + 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. Importing a + # second Pester major into this session would crash with a Pester.dll version conflict + # against the Pester version from requirements.psd1. + PesterLegacy = @{ + Name = 'Pester' + Version = '5.9.0' + Parameters = @{ + SkipPublisherCheck = $true + } + } + + # PlatyPS 1.x, installed side by side with the platyPS 0.14.2 pin in requirements.psd1 + # for the migration in psake/PowerShellBuild#105. Both modules load their own copy of + # YamlDotNet.dll through NestedModules, with different assembly identities -- 0.0.0.0 + # unsigned in platyPS 0.14.2, 15.0.0.0 signed here -- so whichever imports second fails + # with "Assembly with same name is already loaded". The failure is symmetric: neither + # import order works on PowerShell 7, and only a separate process escapes it. + # + # This entry is temporary. It moves into requirements.psd1 when the old module is + # removed in psake/PowerShellBuild#153 and only one PlatyPS remains. + PlatyPSNext = @{ + Name = 'Microsoft.PowerShell.PlatyPS' + Version = '1.0.3' + } +} diff --git a/requirements.pester-matrix.psd1 b/requirements.pester-matrix.psd1 deleted file mode 100644 index cd58532..0000000 --- a/requirements.pester-matrix.psd1 +++ /dev/null @@ -1,18 +0,0 @@ -# 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/Build-PSBuildHelp.tests.ps1 b/tests/Build-PSBuildHelp.tests.ps1 new file mode 100644 index 0000000..88d6c19 --- /dev/null +++ b/tests/Build-PSBuildHelp.tests.ps1 @@ -0,0 +1,276 @@ +# Baseline coverage for the three help-building functions (psake/PowerShellBuild#149). +# +# Build-PSBuildMarkdown, Build-PSBuildMAMLHelp, and Build-PSBuildUpdatableHelp have had no +# tests. The repository does not run its own docs tasks either -- the root psakeFile.ps1 goes +# Init -> Clean -> Build -> Analyze -> Pester -> Publish and never invokes GenerateMarkdown, +# GenerateMAML, or GenerateUpdatableHelp -- so nothing observes these functions today. That +# makes the PlatyPS 1.x migration (#105) a rewrite of three uncovered functions. This file is +# the red-before-green baseline they regress against, written against the CURRENT platyPS +# 0.14.2 behavior. +# +# Every invocation runs in a Start-Job subprocess. That is not incidental: platyPS 0.14.2 and +# Microsoft.PowerShell.PlatyPS 1.x each load their own YamlDotNet.dll through NestedModules, +# with different assembly identities, so whichever imports second fails with "Assembly with +# same name is already loaded". A separate runspace does not escape it; only a separate +# process does. Once the migration starts, the old and new implementations can only be +# exercised in the same test run through subprocesses. +# +# The fixture is copied into $TestDrive rather than built in place, so nothing under tests/ +# is mutated and Pester handles cleanup. + +BeforeDiscovery { + # The psake PreConditions on the docs tasks gate on exactly this, so the tests behave the + # same way the shipped tasks do: absent platyPS means skipped, not failed. + $script:platyPSAvailable = [bool](Get-Module -Name 'platyPS' -ListAvailable) +} + +Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { + + 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 + + $script:fixtureName = 'PSBuildTestFixture' + $script:locale = 'en-US' + + # Runs one PowerShellBuild command in a subprocess and reports what happened rather + # than throwing, so a failure shows up as an assertion on ErrorMessage instead of an + # opaque job error. The timeout matters: a hung job would otherwise stall CI with no + # output, which is the failure mode psake/PowerShellBuild#167 produced. + function script:Invoke-PSBuildCommandJob { + param( + [Parameter(Mandatory)] + [string]$CommandName, + + [Parameter(Mandatory)] + [hashtable]$Parameter, + + [int]$TimeoutSecond = 300 + ) + + $job = Start-Job -ScriptBlock { + param($builtModulePath, $commandName, $parameter) + + Import-Module -Name $builtModulePath -Force -ErrorAction Stop + + $threw = $false + $errorMessage = $null + $commandOutput = @() + try { + $commandOutput = @(& $commandName @parameter -ErrorAction Stop) + } catch { + $threw = $true + $errorMessage = $_.Exception.Message + } + + [PSCustomObject]@{ + Threw = $threw + ErrorMessage = $errorMessage + Output = $commandOutput + } + } -ArgumentList $script:builtModulePath, $CommandName, $Parameter + + $completed = $job | Wait-Job -Timeout $TimeoutSecond + if (-not $completed) { + $job | Stop-Job + Remove-Job -Job $job -Force + return [PSCustomObject]@{ + Threw = $true + ErrorMessage = "$CommandName did not complete within $TimeoutSecond seconds." + Output = @() + } + } + + $jobResult = Receive-Job -Job $job + Remove-Job -Job $job -Force + $jobResult + } + + # Builds an isolated project root holding a copy of the fixture module, and runs the + # markdown step against it. Returns the paths the later steps consume. + function script:New-DocsScenario { + param( + [Parameter(Mandatory)] + [string]$Name + ) + + $projectRoot = Join-Path -Path $TestDrive -ChildPath $Name + $modulePath = Copy-PSBuildTestFixture -Destination $projectRoot + + [PSCustomObject]@{ + ProjectRoot = $projectRoot + ModulePath = $modulePath + DocsPath = Join-Path -Path $projectRoot -ChildPath 'docs' + LocalePath = [IO.Path]::Combine($projectRoot, 'docs', $script:locale) + OutputPath = Join-Path -Path $projectRoot -ChildPath 'Output' + } + } + + function script:New-MarkdownParameter { + param( + [Parameter(Mandatory)] + $Scenario, + + [bool]$Overwrite = $false + ) + + @{ + ModulePath = $Scenario.ModulePath + ModuleName = $script:fixtureName + DocsPath = $Scenario.DocsPath + Locale = $script:locale + Overwrite = $Overwrite + AlphabeticParamsOrder = $false + ExcludeDontShow = $false + UseFullTypeName = $false + } + } + } + + AfterAll { + Remove-Module -Name 'FixtureHelpers' -Force -ErrorAction SilentlyContinue + } + + Context 'Build-PSBuildMarkdown' { + + BeforeAll { + $script:markdownScenario = New-DocsScenario -Name 'markdown' + $script:markdownResult = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMarkdown' -Parameter ( + New-MarkdownParameter -Scenario $script:markdownScenario + ) + } + + It 'completes without error' { + $script:markdownResult.ErrorMessage | Should -BeNullOrEmpty + $script:markdownResult.Threw | Should -BeFalse + } + + It 'creates the locale directory under the docs path' { + $script:markdownScenario.LocalePath | Should -Exist + } + + It 'writes one markdown file per exported command' { + foreach ($commandName in 'Get-Widget', 'Set-Widget') { + Join-Path -Path $script:markdownScenario.LocalePath -ChildPath "$commandName.md" | + Should -Exist + } + } + + It 'writes a module landing page named for the module' -Skip { + # Skipped: New-MarkdownHelp is called without -WithModulePage, so the landing page + # is never produced. That is defect 1 of psake/PowerShellBuild#169 and the reason + # Build-PSBuildUpdatableHelp cannot run at all. Unskip when #169 is fixed. + Join-Path -Path $script:markdownScenario.LocalePath -ChildPath "$script:fixtureName.md" | + Should -Exist + } + + It 'does not document private functions' { + Join-Path -Path $script:markdownScenario.LocalePath -ChildPath 'Test-WidgetName.md' | + Should -Not -Exist + } + + It 'produces markdown carrying the 0.14.x schema marker' { + # The 0.14.x front matter carries "external help file" and "schema: 2.0.0". The 1.x + # schema drops the latter, so this assertion is the tripwire that says the + # migration in #150 actually changed the output format. + $markdown = Get-Content -Path ( + Join-Path -Path $script:markdownScenario.LocalePath -ChildPath 'Get-Widget.md' + ) -Raw + $markdown | Should -Match 'schema:\s*2\.0\.0' + } + } + + Context 'Build-PSBuildMAMLHelp' { + + BeforeAll { + $script:mamlScenario = New-DocsScenario -Name 'maml' + $null = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMarkdown' -Parameter ( + New-MarkdownParameter -Scenario $script:mamlScenario + ) + $script:mamlResult = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMAMLHelp' -Parameter @{ + Path = $script:mamlScenario.DocsPath + DestinationPath = $script:mamlScenario.OutputPath + } + } + + It 'completes without error' { + $script:mamlResult.ErrorMessage | Should -BeNullOrEmpty + $script:mamlResult.Threw | Should -BeFalse + } + + It 'writes the MAML help file into a locale directory under the destination' { + [IO.Path]::Combine($script:mamlScenario.OutputPath, $script:locale, "$script:fixtureName-help.xml") | + Should -Exist + } + + It 'produces MAML describing the exported commands' { + $maml = Get-Content -Path ( + [IO.Path]::Combine($script:mamlScenario.OutputPath, $script:locale, "$script:fixtureName-help.xml") + ) -Raw + $maml | Should -Match 'Get-Widget' + $maml | Should -Match 'Set-Widget' + } + } + + Context 'Build-PSBuildUpdatableHelp' { + + BeforeAll { + $script:cabScenario = New-DocsScenario -Name 'cab' + $null = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMarkdown' -Parameter ( + New-MarkdownParameter -Scenario $script:cabScenario + ) + $null = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMAMLHelp' -Parameter @{ + Path = $script:cabScenario.DocsPath + DestinationPath = $script:cabScenario.OutputPath + } + $script:updatableHelpOutputPath = Join-Path -Path $script:cabScenario.OutputPath -ChildPath 'UpdatableHelp' + $script:cabResult = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildUpdatableHelp' -Parameter @{ + DocsPath = $script:cabScenario.DocsPath + OutputPath = $script:updatableHelpOutputPath + Module = $script:fixtureName + } + } + + It 'declines to run on platforms without makecab' -Skip:($IsWindows -or $null -eq $IsWindows) { + # Guarded by "$null -ne $IsWindows -and -not $IsWindows" in the function, so + # Windows PowerShell 5.1 (where $IsWindows does not exist) never takes this path. + $script:cabResult.Threw | Should -BeFalse + $script:updatableHelpOutputPath | Should -Not -Exist + } + + It 'creates the output directory' -Skip:(-not ($IsWindows -or $null -eq $IsWindows)) { + # This much works today: the directory is created before the cab step throws. + $script:updatableHelpOutputPath | Should -Exist + } + + It 'fails parameter binding on the cab step' -Skip:(-not ($IsWindows -or $null -eq $IsWindows)) { + # Pins the CURRENT broken behavior so the baseline is honest about what happens, + # and so fixing psake/PowerShellBuild#169 forces this test to be revisited rather + # than leaving a silent pass. Delete this test when #169 is fixed; the two below + # replace it. + # + # Either of two independent defects can surface first, depending on the order + # PowerShell binds the splatted parameters: LandingPagePath points at a module page + # that is never generated, and CabFilesFolder is built from the undefined + # $moduleOutDir, which collapses to the bare locale name. Asserting on one of them + # specifically makes this test flaky, so it accepts either. + $script:cabResult.Threw | Should -BeTrue + $script:cabResult.ErrorMessage | Should -Match 'LandingPagePath|CabFilesFolder' + } + + It 'produces a cabinet file' -Skip { + # Skipped pending psake/PowerShellBuild#169. This is the acceptance criterion for + # that fix and for the #152 migration, written now so it is not written twice. + Get-ChildItem -Path $script:updatableHelpOutputPath -Filter '*.cab' | + Should -Not -BeNullOrEmpty + } + + It 'produces the help info manifest' -Skip { + # Skipped pending psake/PowerShellBuild#169. See above. + Get-ChildItem -Path $script:updatableHelpOutputPath -Filter '*HelpInfo.xml' | + Should -Not -BeNullOrEmpty + } + } +} From 4f709ba598e9a01090a309120223929765c8c888 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 24 Aug 2026 11:46:50 -0400 Subject: [PATCH 2/5] chore: Drop the dependency changes from this branch The PlatyPS 1.x install was inherited from #149's premise that the three help functions migrate one at a time. That premise does not hold: the two modules cannot be imported into one session, and the markdown schema changes underneath Build-PSBuildMAMLHelp, so the migrations are atomic. With an atomic migration there is no window where both modules are needed. The swap -- requirements.psd1, the RequiredModules entry, and the six Get-Module platyPS PreConditions -- belongs in the migration commit itself. Installing 1.x ahead of it bought nothing, and the install-only file and rename existed only to serve that install. What is left is the baseline test, which needs only the platyPS 0.14.2 already pinned. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012AKaM9i6NyMFDcJNeC34h5 --- build.ps1 | 6 +++--- requirements.install-only.psd1 | 37 --------------------------------- requirements.pester-matrix.psd1 | 18 ++++++++++++++++ 3 files changed, 21 insertions(+), 40 deletions(-) delete mode 100644 requirements.install-only.psd1 create mode 100644 requirements.pester-matrix.psd1 diff --git a/build.ps1 b/build.ps1 index 4b0d731..4237efb 100644 --- a/build.ps1 +++ b/build.ps1 @@ -42,9 +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: every module in this file conflicts with something - # requirements.psd1 imports. See requirements.install-only.psd1 for the per-module reason. - Invoke-PSDepend -Path './requirements.install-only.psd1' -Install -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.install-only.psd1 b/requirements.install-only.psd1 deleted file mode 100644 index 5bd0adf..0000000 --- a/requirements.install-only.psd1 +++ /dev/null @@ -1,37 +0,0 @@ -# Install-only dependencies. The bootstrap in build.ps1 installs this file WITHOUT importing. -# -# Every entry here exists because importing it into the bootstrap session would break that -# session: two majors of the same module, or two modules that ship conflicting copies of the -# same assembly. They are installed so that tests and build tasks can load them deliberately, -# in a subprocess or at the point of use, rather than implicitly at bootstrap. -@{ - 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. Importing a - # second Pester major into this session would crash with a Pester.dll version conflict - # against the Pester version from requirements.psd1. - PesterLegacy = @{ - Name = 'Pester' - Version = '5.9.0' - Parameters = @{ - SkipPublisherCheck = $true - } - } - - # PlatyPS 1.x, installed side by side with the platyPS 0.14.2 pin in requirements.psd1 - # for the migration in psake/PowerShellBuild#105. Both modules load their own copy of - # YamlDotNet.dll through NestedModules, with different assembly identities -- 0.0.0.0 - # unsigned in platyPS 0.14.2, 15.0.0.0 signed here -- so whichever imports second fails - # with "Assembly with same name is already loaded". The failure is symmetric: neither - # import order works on PowerShell 7, and only a separate process escapes it. - # - # This entry is temporary. It moves into requirements.psd1 when the old module is - # removed in psake/PowerShellBuild#153 and only one PlatyPS remains. - PlatyPSNext = @{ - Name = 'Microsoft.PowerShell.PlatyPS' - Version = '1.0.3' - } -} 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 + } + } +} From 06913fd5dd152d274e026f8696f70e300fb98438 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 24 Aug 2026 12:48:48 -0400 Subject: [PATCH 3/5] refactor: Move the test helpers into FixtureHelpers.psm1 The helpers were defined inside the test file's BeforeAll, following the existing pattern in Test-PSBuildPester.tests.ps1 and Help.tests.ps1. That pattern makes them unreachable from any other test file, and this file's job runner is already a near-duplicate of the one in Test-PSBuildPester.tests.ps1 -- evidence that the reuse is real rather than hypothetical. fixtures/FixtureHelpers.psm1 is where the repository already keeps shared test helpers, so the three move there and the test file keeps only tests. New-PSBuildDocsScenario takes the root directory as a parameter rather than reading $TestDrive, which is a Pester construct and is not visible inside a module scope. The analyzer suppressions follow the form already used in Private/Remove-ExcludedItem.ps1. Two are for the New- verb on functions that either build a hashtable or set up a test fixture, and one is for the runspace rule, which does not model a param() block fed by -ArgumentList and so reports every job parameter as undeclared. Full suite: 461 passed, 0 failed. FixtureHelpers.psm1 analyzes clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012AKaM9i6NyMFDcJNeC34h5 --- tests/Build-PSBuildHelp.tests.ps1 | 210 +++++++++---------------- tests/fixtures/FixtureHelpers.psm1 | 240 ++++++++++++++++++++++++++++- 2 files changed, 309 insertions(+), 141 deletions(-) diff --git a/tests/Build-PSBuildHelp.tests.ps1 b/tests/Build-PSBuildHelp.tests.ps1 index 88d6c19..e10236c 100644 --- a/tests/Build-PSBuildHelp.tests.ps1 +++ b/tests/Build-PSBuildHelp.tests.ps1 @@ -8,12 +8,13 @@ # the red-before-green baseline they regress against, written against the CURRENT platyPS # 0.14.2 behavior. # -# Every invocation runs in a Start-Job subprocess. That is not incidental: platyPS 0.14.2 and +# Every invocation runs in a background job. That is not incidental: platyPS 0.14.2 and # Microsoft.PowerShell.PlatyPS 1.x each load their own YamlDotNet.dll through NestedModules, # with different assembly identities, so whichever imports second fails with "Assembly with # same name is already loaded". A separate runspace does not escape it; only a separate # process does. Once the migration starts, the old and new implementations can only be -# exercised in the same test run through subprocesses. +# exercised in the same test run through subprocesses. See Invoke-PSBuildCommandInJob in +# fixtures/FixtureHelpers.psm1. # # The fixture is copied into $TestDrive rather than built in place, so nothing under tests/ # is mutated and Pester handles cleanup. @@ -22,6 +23,11 @@ BeforeDiscovery { # The psake PreConditions on the docs tasks gate on exactly this, so the tests behave the # same way the shipped tasks do: absent platyPS means skipped, not failed. $script:platyPSAvailable = [bool](Get-Module -Name 'platyPS' -ListAvailable) + + # Build-PSBuildUpdatableHelp returns early on non-Windows, and Windows PowerShell 5.1 has + # no $IsWindows at all, so it takes the Windows path there. Resolved at discovery because + # both branches below are -Skip: conditions. + $script:onWindows = $IsWindows -or $null -eq $IsWindows } Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { @@ -31,102 +37,6 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { $script:builtModulePath = [IO.Path]::Combine($script:moduleRoot, 'Output', 'PowerShellBuild') Import-Module -Name ([IO.Path]::Combine($PSScriptRoot, 'fixtures', 'FixtureHelpers.psm1')) -Force - - $script:fixtureName = 'PSBuildTestFixture' - $script:locale = 'en-US' - - # Runs one PowerShellBuild command in a subprocess and reports what happened rather - # than throwing, so a failure shows up as an assertion on ErrorMessage instead of an - # opaque job error. The timeout matters: a hung job would otherwise stall CI with no - # output, which is the failure mode psake/PowerShellBuild#167 produced. - function script:Invoke-PSBuildCommandJob { - param( - [Parameter(Mandatory)] - [string]$CommandName, - - [Parameter(Mandatory)] - [hashtable]$Parameter, - - [int]$TimeoutSecond = 300 - ) - - $job = Start-Job -ScriptBlock { - param($builtModulePath, $commandName, $parameter) - - Import-Module -Name $builtModulePath -Force -ErrorAction Stop - - $threw = $false - $errorMessage = $null - $commandOutput = @() - try { - $commandOutput = @(& $commandName @parameter -ErrorAction Stop) - } catch { - $threw = $true - $errorMessage = $_.Exception.Message - } - - [PSCustomObject]@{ - Threw = $threw - ErrorMessage = $errorMessage - Output = $commandOutput - } - } -ArgumentList $script:builtModulePath, $CommandName, $Parameter - - $completed = $job | Wait-Job -Timeout $TimeoutSecond - if (-not $completed) { - $job | Stop-Job - Remove-Job -Job $job -Force - return [PSCustomObject]@{ - Threw = $true - ErrorMessage = "$CommandName did not complete within $TimeoutSecond seconds." - Output = @() - } - } - - $jobResult = Receive-Job -Job $job - Remove-Job -Job $job -Force - $jobResult - } - - # Builds an isolated project root holding a copy of the fixture module, and runs the - # markdown step against it. Returns the paths the later steps consume. - function script:New-DocsScenario { - param( - [Parameter(Mandatory)] - [string]$Name - ) - - $projectRoot = Join-Path -Path $TestDrive -ChildPath $Name - $modulePath = Copy-PSBuildTestFixture -Destination $projectRoot - - [PSCustomObject]@{ - ProjectRoot = $projectRoot - ModulePath = $modulePath - DocsPath = Join-Path -Path $projectRoot -ChildPath 'docs' - LocalePath = [IO.Path]::Combine($projectRoot, 'docs', $script:locale) - OutputPath = Join-Path -Path $projectRoot -ChildPath 'Output' - } - } - - function script:New-MarkdownParameter { - param( - [Parameter(Mandatory)] - $Scenario, - - [bool]$Overwrite = $false - ) - - @{ - ModulePath = $Scenario.ModulePath - ModuleName = $script:fixtureName - DocsPath = $Scenario.DocsPath - Locale = $script:locale - Overwrite = $Overwrite - AlphabeticParamsOrder = $false - ExcludeDontShow = $false - UseFullTypeName = $false - } - } } AfterAll { @@ -136,10 +46,13 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { Context 'Build-PSBuildMarkdown' { BeforeAll { - $script:markdownScenario = New-DocsScenario -Name 'markdown' - $script:markdownResult = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMarkdown' -Parameter ( - New-MarkdownParameter -Scenario $script:markdownScenario - ) + $script:markdownScenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'markdown' + $markdownJobParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMarkdown' + Parameter = New-PSBuildMarkdownParameter -Scenario $script:markdownScenario + } + $script:markdownResult = Invoke-PSBuildCommandInJob @markdownJobParameter } It 'completes without error' { @@ -162,7 +75,8 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { # Skipped: New-MarkdownHelp is called without -WithModulePage, so the landing page # is never produced. That is defect 1 of psake/PowerShellBuild#169 and the reason # Build-PSBuildUpdatableHelp cannot run at all. Unskip when #169 is fixed. - Join-Path -Path $script:markdownScenario.LocalePath -ChildPath "$script:fixtureName.md" | + $landingPageName = '{0}.md' -f $script:markdownScenario.ModuleName + Join-Path -Path $script:markdownScenario.LocalePath -ChildPath $landingPageName | Should -Exist } @@ -175,24 +89,31 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { # The 0.14.x front matter carries "external help file" and "schema: 2.0.0". The 1.x # schema drops the latter, so this assertion is the tripwire that says the # migration in #150 actually changed the output format. - $markdown = Get-Content -Path ( - Join-Path -Path $script:markdownScenario.LocalePath -ChildPath 'Get-Widget.md' - ) -Raw - $markdown | Should -Match 'schema:\s*2\.0\.0' + $markdownPath = Join-Path -Path $script:markdownScenario.LocalePath -ChildPath 'Get-Widget.md' + Get-Content -Path $markdownPath -Raw | Should -Match 'schema:\s*2\.0\.0' } } Context 'Build-PSBuildMAMLHelp' { BeforeAll { - $script:mamlScenario = New-DocsScenario -Name 'maml' - $null = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMarkdown' -Parameter ( - New-MarkdownParameter -Scenario $script:mamlScenario - ) - $script:mamlResult = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMAMLHelp' -Parameter @{ - Path = $script:mamlScenario.DocsPath - DestinationPath = $script:mamlScenario.OutputPath + $script:mamlScenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'maml' + $mamlMarkdownJobParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMarkdown' + Parameter = New-PSBuildMarkdownParameter -Scenario $script:mamlScenario + } + $null = Invoke-PSBuildCommandInJob @mamlMarkdownJobParameter + + $mamlJobParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMAMLHelp' + Parameter = @{ + Path = $script:mamlScenario.DocsPath + DestinationPath = $script:mamlScenario.OutputPath + } } + $script:mamlResult = Invoke-PSBuildCommandInJob @mamlJobParameter } It 'completes without error' { @@ -201,14 +122,11 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { } It 'writes the MAML help file into a locale directory under the destination' { - [IO.Path]::Combine($script:mamlScenario.OutputPath, $script:locale, "$script:fixtureName-help.xml") | - Should -Exist + $script:mamlScenario.MamlPath | Should -Exist } It 'produces MAML describing the exported commands' { - $maml = Get-Content -Path ( - [IO.Path]::Combine($script:mamlScenario.OutputPath, $script:locale, "$script:fixtureName-help.xml") - ) -Raw + $maml = Get-Content -Path $script:mamlScenario.MamlPath -Raw $maml | Should -Match 'Get-Widget' $maml | Should -Match 'Set-Widget' } @@ -217,35 +135,47 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { Context 'Build-PSBuildUpdatableHelp' { BeforeAll { - $script:cabScenario = New-DocsScenario -Name 'cab' - $null = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMarkdown' -Parameter ( - New-MarkdownParameter -Scenario $script:cabScenario - ) - $null = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildMAMLHelp' -Parameter @{ - Path = $script:cabScenario.DocsPath - DestinationPath = $script:cabScenario.OutputPath + $script:cabScenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'cab' + $cabMarkdownJobParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMarkdown' + Parameter = New-PSBuildMarkdownParameter -Scenario $script:cabScenario + } + $null = Invoke-PSBuildCommandInJob @cabMarkdownJobParameter + + $cabMamlJobParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMAMLHelp' + Parameter = @{ + Path = $script:cabScenario.DocsPath + DestinationPath = $script:cabScenario.OutputPath + } } - $script:updatableHelpOutputPath = Join-Path -Path $script:cabScenario.OutputPath -ChildPath 'UpdatableHelp' - $script:cabResult = Invoke-PSBuildCommandJob -CommandName 'Build-PSBuildUpdatableHelp' -Parameter @{ - DocsPath = $script:cabScenario.DocsPath - OutputPath = $script:updatableHelpOutputPath - Module = $script:fixtureName + $null = Invoke-PSBuildCommandInJob @cabMamlJobParameter + + $cabJobParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildUpdatableHelp' + Parameter = @{ + DocsPath = $script:cabScenario.DocsPath + OutputPath = $script:cabScenario.UpdatableHelpPath + Module = $script:cabScenario.ModuleName + } } + $script:cabResult = Invoke-PSBuildCommandInJob @cabJobParameter } - It 'declines to run on platforms without makecab' -Skip:($IsWindows -or $null -eq $IsWindows) { - # Guarded by "$null -ne $IsWindows -and -not $IsWindows" in the function, so - # Windows PowerShell 5.1 (where $IsWindows does not exist) never takes this path. + It 'declines to run on platforms without makecab' -Skip:$script:onWindows { $script:cabResult.Threw | Should -BeFalse - $script:updatableHelpOutputPath | Should -Not -Exist + $script:cabScenario.UpdatableHelpPath | Should -Not -Exist } - It 'creates the output directory' -Skip:(-not ($IsWindows -or $null -eq $IsWindows)) { + It 'creates the output directory' -Skip:(-not $script:onWindows) { # This much works today: the directory is created before the cab step throws. - $script:updatableHelpOutputPath | Should -Exist + $script:cabScenario.UpdatableHelpPath | Should -Exist } - It 'fails parameter binding on the cab step' -Skip:(-not ($IsWindows -or $null -eq $IsWindows)) { + It 'fails parameter binding on the cab step' -Skip:(-not $script:onWindows) { # Pins the CURRENT broken behavior so the baseline is honest about what happens, # and so fixing psake/PowerShellBuild#169 forces this test to be revisited rather # than leaving a silent pass. Delete this test when #169 is fixed; the two below @@ -263,13 +193,13 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { It 'produces a cabinet file' -Skip { # Skipped pending psake/PowerShellBuild#169. This is the acceptance criterion for # that fix and for the #152 migration, written now so it is not written twice. - Get-ChildItem -Path $script:updatableHelpOutputPath -Filter '*.cab' | + Get-ChildItem -Path $script:cabScenario.UpdatableHelpPath -Filter '*.cab' | Should -Not -BeNullOrEmpty } It 'produces the help info manifest' -Skip { # Skipped pending psake/PowerShellBuild#169. See above. - Get-ChildItem -Path $script:updatableHelpOutputPath -Filter '*HelpInfo.xml' | + Get-ChildItem -Path $script:cabScenario.UpdatableHelpPath -Filter '*HelpInfo.xml' | Should -Not -BeNullOrEmpty } } diff --git a/tests/fixtures/FixtureHelpers.psm1 b/tests/fixtures/FixtureHelpers.psm1 index f43e398..4ded4d7 100644 --- a/tests/fixtures/FixtureHelpers.psm1 +++ b/tests/fixtures/FixtureHelpers.psm1 @@ -42,4 +42,242 @@ function Copy-PSBuildTestFixture { $fixtureCopyPath } -Export-ModuleMember -Function 'Copy-PSBuildTestFixture' +function New-PSBuildDocsScenario { + <# + .SYNOPSIS + Create an isolated project directory holding a copy of the test fixture, plus the paths + the help building functions read and write. + .DESCRIPTION + Builds the directory layout the docs pipeline expects -- a project root containing a + same-named module subdirectory, matching the shape Set-BuildEnvironment resolves -- and + returns the paths derived from it so tests do not recompute them. + + Only the project root and the fixture copy are created. The docs and output directories + are returned as paths, because the functions under test are responsible for creating + them and a test that pre-created them could not tell the difference. + .PARAMETER Path + Directory to create the scenario under, typically $TestDrive. Passed in rather than read + from the caller because $TestDrive is a Pester construct and is not visible inside a + module scope. + .PARAMETER Name + Name of the scenario directory. Give each scenario its own name so that scenarios in the + same test run cannot observe each other's output. + .PARAMETER Locale + Help locale the scenario is built for. Defaults to en-US, which is what the fixture's + comment-based help is written in. + .EXAMPLE + PS> $scenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'markdown' + + Creates $TestDrive/markdown/PSBuildTestFixture and returns the scenario paths. + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + # Creates a scenario directory but is a test helper, not a user-facing command; a + # -WhatIf that skipped the copy would leave every caller with nothing to test. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Path, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Name, + + [ValidateNotNullOrEmpty()] + [string] + $Locale = 'en-US' + ) + + $projectRoot = Join-Path -Path $Path -ChildPath $Name + $modulePath = Copy-PSBuildTestFixture -Destination $projectRoot + $outputPath = Join-Path -Path $projectRoot -ChildPath 'Output' + + [PSCustomObject]@{ + ProjectRoot = $projectRoot + ModulePath = $modulePath + ModuleName = 'PSBuildTestFixture' + Locale = $Locale + DocsPath = Join-Path -Path $projectRoot -ChildPath 'docs' + LocalePath = [IO.Path]::Combine($projectRoot, 'docs', $Locale) + OutputPath = $outputPath + MamlPath = [IO.Path]::Combine($outputPath, $Locale, 'PSBuildTestFixture-help.xml') + UpdatableHelpPath = Join-Path -Path $outputPath -ChildPath 'UpdatableHelp' + } +} + +function New-PSBuildMarkdownParameter { + <# + .SYNOPSIS + Build the Build-PSBuildMarkdown parameter set for a docs scenario. + .DESCRIPTION + Build-PSBuildMarkdown takes four mandatory [bool] parameters that most tests do not care + about but cannot omit. This supplies them at their build.properties.ps1 defaults so a + test only has to name the ones it is actually exercising. + .PARAMETER Scenario + Scenario object from New-PSBuildDocsScenario. + .PARAMETER Overwrite + Whether comment-based help overwrites existing markdown. Defaults to $false, matching + $PSBPreference.Docs.Overwrite. + .PARAMETER AlphabeticParamsOrder + Whether parameters are ordered alphabetically. Defaults to $false, matching + $PSBPreference.Docs.AlphabeticParamsOrder. + .PARAMETER ExcludeDontShow + Whether parameters marked DontShow are excluded. Defaults to $false, matching + $PSBPreference.Docs.ExcludeDontShow. + .PARAMETER UseFullTypeName + Whether full type names are used. Defaults to $false, matching + $PSBPreference.Docs.UseFullTypeName. + .EXAMPLE + PS> $parameter = New-PSBuildMarkdownParameter -Scenario $scenario + + Returns the default parameter set for the scenario. + .OUTPUTS + System.Collections.Hashtable + #> + # Builds and returns a hashtable; it changes nothing. The rule fires on the New- verb + # alone, and New- is the accurate verb for what this does. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [PSCustomObject] + $Scenario, + + [bool] + $Overwrite = $false, + + [bool] + $AlphabeticParamsOrder = $false, + + [bool] + $ExcludeDontShow = $false, + + [bool] + $UseFullTypeName = $false + ) + + @{ + ModulePath = $Scenario.ModulePath + ModuleName = $Scenario.ModuleName + DocsPath = $Scenario.DocsPath + Locale = $Scenario.Locale + Overwrite = $Overwrite + AlphabeticParamsOrder = $AlphabeticParamsOrder + ExcludeDontShow = $ExcludeDontShow + UseFullTypeName = $UseFullTypeName + } +} + +function Invoke-PSBuildCommandInJob { + <# + .SYNOPSIS + Run one PowerShellBuild command in a background job and report what happened. + .DESCRIPTION + Imports the built PowerShellBuild module inside a background job, invokes the named + command there, and returns a result object rather than throwing. Reporting instead of + throwing keeps a failure legible: the test asserts on ErrorMessage instead of an opaque + job error. + + A job is used because some commands cannot be exercised in the caller's session. The + docs pipeline is the current example: platyPS 0.14.2 and Microsoft.PowerShell.PlatyPS + 1.x each load their own YamlDotNet.dll through NestedModules with different assembly + identities, so whichever imports second fails with "Assembly with same name is already + loaded". A separate runspace does not escape that; only a separate process does. Pester + recommends the same technique for session isolation, see + https://pester.dev/docs/usage/mocking. + .PARAMETER ModulePath + Path to the built PowerShellBuild module to import inside the job. + .PARAMETER CommandName + Name of the command to invoke. + .PARAMETER Parameter + Parameters to splat onto the command. + .PARAMETER TimeoutSecond + How long to wait before giving up. A hung job would otherwise stall CI with no output + and no error, so the timeout is reported as a failure like any other. Defaults to 300. + .EXAMPLE + PS> $result = Invoke-PSBuildCommandInJob -ModulePath $builtModulePath -CommandName 'Build-PSBuildMarkdown' -Parameter $parameter + + Runs Build-PSBuildMarkdown in a job and returns Threw, ErrorMessage, and Output. + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + # The job scriptblock declares its own param() block and receives values through + # -ArgumentList, which is the documented alternative to $using:. The analyzer does not + # model that pairing and reports every parameter as an undeclared variable. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $CommandName, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] + $Parameter, + + [ValidateRange(1, 3600)] + [int] + $TimeoutSecond = 300 + ) + + $job = Start-Job -ScriptBlock { + param($modulePath, $commandName, $parameter) + + Import-Module -Name $modulePath -Force -ErrorAction Stop + + $threw = $false + $errorMessage = $null + # Capture the command's own output rather than letting it fall through to the job's + # output stream, where it would be interleaved with the result object below. + $commandOutput = @() + try { + $commandOutput = @(& $commandName @parameter -ErrorAction Stop) + } catch { + $threw = $true + $errorMessage = $_.Exception.Message + } + + [PSCustomObject]@{ + Threw = $threw + ErrorMessage = $errorMessage + Output = $commandOutput + } + } -ArgumentList $ModulePath, $CommandName, $Parameter + + $completedJob = Wait-Job -Job $job -Timeout $TimeoutSecond + if (-not $completedJob) { + Stop-Job -Job $job + Remove-Job -Job $job -Force + return [PSCustomObject]@{ + Threw = $true + ErrorMessage = "$CommandName did not complete within $TimeoutSecond seconds." + Output = @() + } + } + + $jobResult = Receive-Job -Job $job + Remove-Job -Job $job -Force + $jobResult +} + +Export-ModuleMember -Function @( + 'Copy-PSBuildTestFixture' + 'Invoke-PSBuildCommandInJob' + 'New-PSBuildDocsScenario' + 'New-PSBuildMarkdownParameter' +) From 6c462085d358e8ff6f8a48c426bd6c9bcf970008 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 24 Aug 2026 12:58:32 -0400 Subject: [PATCH 4/5] refactor: Converge Test-PSBuildPester.tests.ps1 on the shared job runner That file carried its own Invoke-TestPSBuildPesterJob, defined in BeforeAll, which duplicated the job mechanics now in fixtures/FixtureHelpers.psm1: start a job, import the built module, invoke one command, capture rather than throw, clean the job up. Invoke-PSBuildCommandInJob gains a RequiredModule parameter -- a name-to-version map imported inside the job before the PowerShellBuild module -- which is what the Pester matrix needed and the only real difference between the two implementations. It reports back what actually loaded in LoadedModuleVersion, so the "honors the Pester version that is already loaded" test asserts on the observed version rather than the requested one, as it did before. Invoke-TestPSBuildPesterInJob keeps the call sites short. It supplies the three parameters every scenario passes and forwards the rest, so the ten call sites change only by naming the module path. AdditionalParameters is renamed to the singular AdditionalParameter, matching the module's other parameter names. Test-PSBuildPester.tests.ps1: 18 passed, 0 failed, both inner Pester majors exercised. Full suite: 461 passed, 0 failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012AKaM9i6NyMFDcJNeC34h5 --- tests/Test-PSBuildPester.tests.ps1 | 76 ++++-------------- tests/fixtures/FixtureHelpers.psm1 | 121 +++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 71 deletions(-) diff --git a/tests/Test-PSBuildPester.tests.ps1 b/tests/Test-PSBuildPester.tests.ps1 index 43a3c8a..7a6c56e 100644 --- a/tests/Test-PSBuildPester.tests.ps1 +++ b/tests/Test-PSBuildPester.tests.ps1 @@ -4,7 +4,8 @@ # 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. +# shipped function keeps supporting Pester 5 consumers. The job runner itself lives in +# fixtures/FixtureHelpers.psm1, shared with the other test files that need a fresh session. # # 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). @@ -34,57 +35,6 @@ Describe 'Test-PSBuildPester' { 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 - # Capture the command's output rather than letting it fall through to the job's - # output stream, where the coverage report lines would be interleaved with the - # result object below. - $commandOutput = @() - try { - $commandOutput = @(Test-PSBuildPester @testPSBuildPesterParameters) - } catch { - $threw = $true - $errorMessage = $_.Exception.Message - } - - [PSCustomObject]@{ - Threw = $threw - ErrorMessage = $errorMessage - Output = $commandOutput - 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' @@ -169,14 +119,14 @@ Describe 'Coverage target' { } It 'succeeds for a healthy suite' { - $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:healthyPath + $result = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -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 = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:innerVersion -Path $script:failingTestPath $result.Threw | Should -BeTrue $result.ErrorMessage | Should -Match 'Pester tests failed' @@ -184,7 +134,7 @@ Describe 'Coverage target' { 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 = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:innerVersion -Path $script:beforeAllCrashPath $result.Threw | Should -BeTrue $result.ErrorMessage | Should -Match 'Pester tests failed' @@ -192,7 +142,7 @@ Describe 'Coverage target' { 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 = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:innerVersion -Path $script:discoveryCrashPath $result.Threw | Should -BeTrue $result.ErrorMessage | Should -Match 'Pester tests failed' @@ -203,7 +153,7 @@ Describe 'Coverage target' { $additionalParameters = @{ OutputPath = $testResultsPath } - $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:healthyPath -AdditionalParameters $additionalParameters + $result = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:innerVersion -Path $script:healthyPath -AdditionalParameter $additionalParameters $result.Threw | Should -BeFalse $testResultsPath | Should -Exist @@ -218,7 +168,7 @@ Describe 'Coverage target' { CodeCoverageOutputFile = $coverageOutputPath CodeCoverageOutputFileFormat = 'JaCoCo' } - $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:coveragePath -AdditionalParameters $additionalParameters + $result = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:innerVersion -Path $script:coveragePath -AdditionalParameter $additionalParameters $result.Threw | Should -BeFalse $coverageOutputPath | Should -Exist @@ -239,7 +189,7 @@ Describe 'Coverage target' { CodeCoverageOutputFile = $coverageOutputPath CodeCoverageThreshold = 0.01 } - $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:coveragePath -AdditionalParameters $additionalParameters + $result = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:innerVersion -Path $script:coveragePath -AdditionalParameter $additionalParameters $result.Threw | Should -BeFalse } @@ -253,7 +203,7 @@ Describe 'Coverage target' { CodeCoverageOutputFile = $coverageOutputPath CodeCoverageThreshold = 0.99 } - $result = Invoke-TestPSBuildPesterJob -InnerPesterVersion $script:innerVersion -Path $script:coveragePath -AdditionalParameters $additionalParameters + $result = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:innerVersion -Path $script:coveragePath -AdditionalParameter $additionalParameters $result.Threw | Should -BeTrue $result.ErrorMessage | Should -Match 'less than the threshold' @@ -275,7 +225,7 @@ Describe 'Coverage target' { # 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 = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:newestInnerVersion -Path $script:healthyPath $result.Threw | Should -BeFalse $result.ErrorMessage | Should -BeNullOrEmpty @@ -285,10 +235,10 @@ Describe 'Coverage target' { # 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 = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:oldestInnerVersion -Path $script:healthyPath $result.Threw | Should -BeFalse - $result.LoadedPesterVersions | Should -Be @($script:oldestInnerVersion) + $result.LoadedModuleVersion['Pester'] | Should -Be @($script:oldestInnerVersion) } } } diff --git a/tests/fixtures/FixtureHelpers.psm1 b/tests/fixtures/FixtureHelpers.psm1 index 4ded4d7..f2f20ff 100644 --- a/tests/fixtures/FixtureHelpers.psm1 +++ b/tests/fixtures/FixtureHelpers.psm1 @@ -198,6 +198,12 @@ function Invoke-PSBuildCommandInJob { Name of the command to invoke. .PARAMETER Parameter Parameters to splat onto the command. + .PARAMETER RequiredModule + Modules to import at an exact version inside the job, before the PowerShellBuild module + is imported, as a name-to-version map. Use this when the command's behavior depends on + which version of a dependency is loaded. Each named module's loaded versions are + reported back in LoadedModuleVersion so a test can assert what actually loaded rather + than what it asked for. .PARAMETER TimeoutSecond How long to wait before giving up. A hung job would otherwise stall CI with no output and no error, so the timeout is reported as a failure like any other. Defaults to 300. @@ -230,13 +236,23 @@ function Invoke-PSBuildCommandInJob { [hashtable] $Parameter, + [ValidateNotNull()] + [hashtable] + $RequiredModule = @{}, + [ValidateRange(1, 3600)] [int] $TimeoutSecond = 300 ) $job = Start-Job -ScriptBlock { - param($modulePath, $commandName, $parameter) + param($modulePath, $commandName, $parameter, $requiredModule) + + # Imported before the PowerShellBuild module so that a dependency the module would + # otherwise autoload is already present at the requested version. + foreach ($moduleName in $requiredModule.Keys) { + Import-Module -Name $moduleName -RequiredVersion $requiredModule[$moduleName] -ErrorAction Stop + } Import-Module -Name $modulePath -Force -ErrorAction Stop @@ -252,21 +268,32 @@ function Invoke-PSBuildCommandInJob { $errorMessage = $_.Exception.Message } + # Report what is loaded after the call, not before: the point is to catch a command + # that pulled in a different version than the one requested. + $loadedModuleVersion = @{} + foreach ($moduleName in $requiredModule.Keys) { + $loadedModuleVersion[$moduleName] = @( + (Get-Module -Name $moduleName).Version.ForEach({ $_.ToString() }) + ) + } + [PSCustomObject]@{ - Threw = $threw - ErrorMessage = $errorMessage - Output = $commandOutput + Threw = $threw + ErrorMessage = $errorMessage + Output = $commandOutput + LoadedModuleVersion = $loadedModuleVersion } - } -ArgumentList $ModulePath, $CommandName, $Parameter + } -ArgumentList $ModulePath, $CommandName, $Parameter, $RequiredModule $completedJob = Wait-Job -Job $job -Timeout $TimeoutSecond if (-not $completedJob) { Stop-Job -Job $job Remove-Job -Job $job -Force return [PSCustomObject]@{ - Threw = $true - ErrorMessage = "$CommandName did not complete within $TimeoutSecond seconds." - Output = @() + Threw = $true + ErrorMessage = "$CommandName did not complete within $TimeoutSecond seconds." + Output = @() + LoadedModuleVersion = @{} } } @@ -275,9 +302,87 @@ function Invoke-PSBuildCommandInJob { $jobResult } +function Invoke-TestPSBuildPesterInJob { + <# + .SYNOPSIS + Run Test-PSBuildPester in a background job against a pinned inner Pester version. + .DESCRIPTION + A thin shape over Invoke-PSBuildCommandInJob for the Test-PSBuildPester integration + matrix, which invokes the command many times and varies only the scenario path, the + inner Pester version, and a few extra parameters. + + Testing Test-PSBuildPester means Pester testing Pester, so the job is doing two jobs at + once: it gives the inner run its own session, and it lets that session pin a Pester + version independently of the outer framework. Two Pester majors cannot coexist in one + session, so without the job the matrix could only ever cover the version already loaded. + .PARAMETER ModulePath + Path to the built PowerShellBuild module to import inside the job. + .PARAMETER InnerPesterVersion + Exact Pester version to import inside the job before Test-PSBuildPester runs. + .PARAMETER Path + Scenario directory to point Test-PSBuildPester at. + .PARAMETER AdditionalParameter + Extra parameters for Test-PSBuildPester, merged over the defaults. Supply a key already + in the defaults to override it. + .PARAMETER TimeoutSecond + How long to wait before giving up. Defaults to 300. + .EXAMPLE + PS> $result = Invoke-TestPSBuildPesterInJob -ModulePath $builtModulePath -InnerPesterVersion '6.0.0' -Path $healthyPath + + Runs Test-PSBuildPester against the healthy scenario under Pester 6.0.0. + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $InnerPesterVersion, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Path, + + [ValidateNotNull()] + [hashtable] + $AdditionalParameter = @{}, + + [ValidateRange(1, 3600)] + [int] + $TimeoutSecond = 300 + ) + + $testPSBuildPesterParameter = @{ + Path = $Path + OutputVerbosity = 'None' + ErrorAction = 'Stop' + } + foreach ($key in $AdditionalParameter.Keys) { + $testPSBuildPesterParameter[$key] = $AdditionalParameter[$key] + } + + $jobParameter = @{ + ModulePath = $ModulePath + CommandName = 'Test-PSBuildPester' + Parameter = $testPSBuildPesterParameter + RequiredModule = @{ Pester = $InnerPesterVersion } + TimeoutSecond = $TimeoutSecond + } + Invoke-PSBuildCommandInJob @jobParameter +} + Export-ModuleMember -Function @( 'Copy-PSBuildTestFixture' 'Invoke-PSBuildCommandInJob' + 'Invoke-TestPSBuildPesterInJob' 'New-PSBuildDocsScenario' 'New-PSBuildMarkdownParameter' ) From 35f7763aeb52a5f1a297c20bc580c60934ea2369 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 24 Aug 2026 13:08:03 -0400 Subject: [PATCH 5/5] fix: Default ErrorAction into the splat instead of passing it alongside Invoke-PSBuildCommandInJob invoked the command as "& $commandName @parameter -ErrorAction Stop", while Invoke-TestPSBuildPesterInJob also carries ErrorAction inside the splatted hashtable, as the runner it replaced did. Supplying a parameter both ways is fatal on Windows PowerShell 5.1: Cannot bind parameter because parameter 'ErrorAction' is specified more than once. PowerShell 7 accepts it, so every pwsh leg passed and only the 5.1 leg failed -- 16 tests in Test-PSBuildPester.tests.ps1. Verifying locally on pwsh alone was not enough; this is the class of break that leg exists to catch. ErrorAction is now defaulted into the splat only when the caller has not already set it, which fixes the collision and lets a caller ask for different behavior. Verified on Windows PowerShell 5.1: 19 passed, 0 failed, 5 skipped. Verified on PowerShell 7.6.5: 28 passed, 0 failed, 4 skipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012AKaM9i6NyMFDcJNeC34h5 --- tests/fixtures/FixtureHelpers.psm1 | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/FixtureHelpers.psm1 b/tests/fixtures/FixtureHelpers.psm1 index f2f20ff..744a2ee 100644 --- a/tests/fixtures/FixtureHelpers.psm1 +++ b/tests/fixtures/FixtureHelpers.psm1 @@ -256,13 +256,25 @@ function Invoke-PSBuildCommandInJob { Import-Module -Name $modulePath -Force -ErrorAction Stop + # Default ErrorAction into the splat rather than passing it alongside. Supplying it + # both ways is fatal on Windows PowerShell 5.1 -- "Cannot bind parameter because + # parameter 'ErrorAction' is specified more than once" -- even though PowerShell 7 + # accepts it. Defaulting it here also lets a caller ask for different behavior. + $invokeParameter = @{} + foreach ($key in $parameter.Keys) { + $invokeParameter[$key] = $parameter[$key] + } + if (-not $invokeParameter.ContainsKey('ErrorAction')) { + $invokeParameter['ErrorAction'] = 'Stop' + } + $threw = $false $errorMessage = $null # Capture the command's own output rather than letting it fall through to the job's # output stream, where it would be interleaved with the result object below. $commandOutput = @() try { - $commandOutput = @(& $commandName @parameter -ErrorAction Stop) + $commandOutput = @(& $commandName @invokeParameter) } catch { $threw = $true $errorMessage = $_.Exception.Message