diff --git a/tests/Build-PSBuildHelp.tests.ps1 b/tests/Build-PSBuildHelp.tests.ps1 new file mode 100644 index 0000000..e10236c --- /dev/null +++ b/tests/Build-PSBuildHelp.tests.ps1 @@ -0,0 +1,206 @@ +# 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 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. 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. + +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) { + + 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 + } + + AfterAll { + Remove-Module -Name 'FixtureHelpers' -Force -ErrorAction SilentlyContinue + } + + Context 'Build-PSBuildMarkdown' { + + BeforeAll { + $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' { + $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. + $landingPageName = '{0}.md' -f $script:markdownScenario.ModuleName + Join-Path -Path $script:markdownScenario.LocalePath -ChildPath $landingPageName | + 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. + $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-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' { + $script:mamlResult.ErrorMessage | Should -BeNullOrEmpty + $script:mamlResult.Threw | Should -BeFalse + } + + It 'writes the MAML help file into a locale directory under the destination' { + $script:mamlScenario.MamlPath | Should -Exist + } + + It 'produces MAML describing the exported commands' { + $maml = Get-Content -Path $script:mamlScenario.MamlPath -Raw + $maml | Should -Match 'Get-Widget' + $maml | Should -Match 'Set-Widget' + } + } + + Context 'Build-PSBuildUpdatableHelp' { + + BeforeAll { + $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 + } + } + $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:$script:onWindows { + $script:cabResult.Threw | Should -BeFalse + $script:cabScenario.UpdatableHelpPath | Should -Not -Exist + } + + It 'creates the output directory' -Skip:(-not $script:onWindows) { + # This much works today: the directory is created before the cab step throws. + $script:cabScenario.UpdatableHelpPath | Should -Exist + } + + 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 + # 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: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:cabScenario.UpdatableHelpPath -Filter '*HelpInfo.xml' | + Should -Not -BeNullOrEmpty + } + } +} 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 f43e398..744a2ee 100644 --- a/tests/fixtures/FixtureHelpers.psm1 +++ b/tests/fixtures/FixtureHelpers.psm1 @@ -42,4 +42,359 @@ 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 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. + .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, + + [ValidateNotNull()] + [hashtable] + $RequiredModule = @{}, + + [ValidateRange(1, 3600)] + [int] + $TimeoutSecond = 300 + ) + + $job = Start-Job -ScriptBlock { + 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 + + # 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 @invokeParameter) + } catch { + $threw = $true + $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 + LoadedModuleVersion = $loadedModuleVersion + } + } -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 = @() + LoadedModuleVersion = @{} + } + } + + $jobResult = Receive-Job -Job $job + Remove-Job -Job $job -Force + $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' +)