diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ceed4..d59aebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,27 @@ Everything below is the detail, one entry per issue. ### Fixed +- [**#221**](https://github.com/psake/PowerShellBuild/issues/221), + [**#222**](https://github.com/psake/PowerShellBuild/issues/222) + A build no longer unloads modules from the session it runs in. + `Build-PSBuildMarkdown` and `Test-PSBuildPester` both ended with + `Remove-Module -Name `, which removes *every* loaded module of that + name — including a copy you loaded yourself and neither function ever imported. + Both are on default paths: `Build` depends on `BuildHelp` depends on + `GenerateMarkdown`, and `Test` depends on `Pester`. `Test-PSBuildPester` was the + worse of the two, because its import is conditional on `-ImportModule` (which + defaults to `$false`) while its removal was not — so on the default `Test` chain + it removed a module it had never touched. The symptom was a command that worked a + moment ago no longer being recognized, with nothing in the build output to explain + it; `PowerShellOrg/PSDepend` gave up generated documentation entirely rather than + live with it. Both functions now record what was loaded before they import, + remove only the instance they created, and restore what they displaced — + including on the zero-export path, where `Build-PSBuildMarkdown` warns and + returns without generating anything. `Build-PSBuildMarkdown` also no longer + imports with `-Global`: PlatyPS resolves the module through the `PSModuleInfo` + object rather than by name, so the import no longer reaches into your session + state at all. The generated markdown is unchanged. + - [**#206**](https://github.com/psake/PowerShellBuild/issues/206) `Build-PSBuildModule -Compile` no longer compiles your working directory when no compile directories are given. `CompileDirectories` defaulted to `@()`, and diff --git a/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 b/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 index 24fc461..4493149 100644 --- a/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 @@ -51,7 +51,22 @@ function Build-PSBuildMarkdown { [bool]$UseFullTypeName ) - $moduleInfo = Import-Module "$ModulePath/$ModuleName.psd1" -Global -Force -PassThru + # Record every copy of the module the caller already had loaded, so the finally block can + # put the session back the way it was found. Two separate things reach a caller's module + # otherwise: -Force evicts a copy loaded from this same path before the finally block is + # ever reached, and the removal that used to run there was by name, which takes every + # loaded copy regardless of who imported it or from where (psake/PowerShellBuild#221). + # + # -Force nevertheless stays. Without it, importing an already-loaded module is a no-op that + # returns the cached PSModuleInfo, so ExportedCommands would describe the previous build's + # command surface and the generated markdown would silently document a stale module. + # + # -Global is deliberately absent. Microsoft.PowerShell.PlatyPS resolves the module through + # the PSModuleInfo passed as -ModuleInfo below rather than by name, so it never performs a + # session-state lookup that -Global would serve. The generated markdown is byte-for-byte + # identical without it, and the import stops reaching into the caller's session state. + $previouslyLoadedModule = @(Get-Module -Name $ModuleName) + $moduleInfo = Import-Module "$ModulePath/$ModuleName.psd1" -Force -PassThru try { if ($moduleInfo.ExportedCommands.Count -eq 0) { @@ -149,6 +164,16 @@ function Build-PSBuildMarkdown { } catch { Write-Error ($LocalizedData.FailedToGenerateMarkdownHelp -f $_) } finally { - Remove-Module $ModuleName + # Remove only the instance this function imported. The zero-export path above returns + # from inside the try block, which still runs this, so a module with no exported + # commands generates nothing and leaves the caller's session untouched all the same. + Remove-Module -ModuleInfo $moduleInfo -Force -ErrorAction SilentlyContinue + + # Restored into the global session state, because that is where a caller's copy lives. + # An import issued from inside this module without -Global would only reach + # PowerShellBuild's own session state, and the caller would still see nothing. + foreach ($restoredModule in $previouslyLoadedModule) { + Import-Module -ModuleInfo $restoredModule -Global -ErrorAction SilentlyContinue + } } } diff --git a/PowerShellBuild/Public/Test-PSBuildPester.ps1 b/PowerShellBuild/Public/Test-PSBuildPester.ps1 index 33dfca3..25ff647 100644 --- a/PowerShellBuild/Public/Test-PSBuildPester.ps1 +++ b/PowerShellBuild/Public/Test-PSBuildPester.ps1 @@ -78,14 +78,27 @@ function Test-PSBuildPester { throw ($LocalizedData.PesterVersionNotSupported -f $loadedPester.Version) } + # Nothing is imported unless -ImportModule is passed, so these stay empty on the default + # path and the finally block below has nothing to undo (psake/PowerShellBuild#222). + $previouslyLoadedModule = @() + $importedModule = $null + try { if ($ImportModule) { if (-not (Test-Path $ModuleManifest)) { Write-Error ($LocalizedData.UnableToFindModuleManifest -f $ModuleManifest) } else { - # Remove any previously imported project modules and import from the output dir - Get-Module $ModuleName | Remove-Module -Force -ErrorAction SilentlyContinue - Import-Module $ModuleManifest -Force + # Remove any previously imported project modules and import from the output + # dir, so the tests run against the module that was just built rather than a + # copy the session happened to be holding. What the caller had loaded is + # recorded first and restored in the finally block; ModuleName guards the + # lookup because Get-Module rejects an empty -Name with a parameter-binding + # error that -ErrorAction SilentlyContinue cannot suppress. + if ($ModuleName) { + $previouslyLoadedModule = @(Get-Module -Name $ModuleName) + $previouslyLoadedModule | Remove-Module -Force -ErrorAction SilentlyContinue + } + $importedModule = Import-Module -Name $ModuleManifest -Force -PassThru } } @@ -153,10 +166,21 @@ function Test-PSBuildPester { } } finally { Pop-Location - # 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 + + # Remove only the instance this function imported. The removal used to run by name and + # unconditionally, so on the default path -- where ImportModule is $false and nothing + # is imported at all -- it unloaded whatever the caller happened to have loaded under + # that name. Keying on the imported instance also retires the empty -Name guard that + # used to stand here, because there is no name-based removal left to protect. + if ($importedModule) { + Remove-Module -ModuleInfo $importedModule -Force -ErrorAction SilentlyContinue + } + + # Restored into the global session state, because that is where a caller's copy lives. + # An import issued from inside this module without -Global would only reach + # PowerShellBuild's own session state, and the caller would still see nothing. + foreach ($restoredModule in $previouslyLoadedModule) { + Import-Module -ModuleInfo $restoredModule -Global -ErrorAction SilentlyContinue } } } diff --git a/docs/migration-v0.8-to-v1.0.md b/docs/migration-v0.8-to-v1.0.md index af19ce3..10b7708 100644 --- a/docs/migration-v0.8-to-v1.0.md +++ b/docs/migration-v0.8-to-v1.0.md @@ -63,6 +63,12 @@ One line per break; follow the link for details and migration steps. - [`$PSBPreference.Sign.SkipCertificateValidation` now has an effect](#psbpreferencesignskipcertificatevalidation-now-has-an-effect) — the escape hatch did nothing on 0.8.x; a build that failed on an expired certificate may now succeed by signing with it. +- [The `GenerateMarkdown` task no longer unloads your module](#the-generatemarkdown-task-no-longer-unloads-your-module) + — building help emptied the session it ran in; if you dropped the docs + tasks to avoid that, you can put them back. +- [The `Pester` task no longer unloads a module it never imported](#the-pester-task-no-longer-unloads-a-module-it-never-imported) + — the same defect on the default `Test` chain, where nothing was imported + to justify the removal at all. ## AI-assisted migration @@ -1012,6 +1018,84 @@ Note the two source families differ, and the difference matters: Tracked in [#193](https://github.com/psake/PowerShellBuild/issues/193). +### The `GenerateMarkdown` task no longer unloads your module + +On 0.8.x, `Build-PSBuildMarkdown` ended with +`Remove-Module -Name ` in its `finally` block. `Remove-Module +-Name` removes **every** loaded module with that name — including a copy +you loaded yourself, from a different path, that the function never +imported. Generating documentation emptied the session it ran in. + +This was on by default. `Build` depends on `BuildHelp`, which depends on +`GenerateMarkdown`, so every consumer who did not override +`$PSBBuildDependency` ran it on every build. + +On 1.0.0 the function records the copies you had loaded, removes only the +instance it imported itself, and re-imports what it displaced — including +on the zero-export path, where it warns +`No commands have been exported. Skipping markdown generation.` and +returns without generating anything. + +**Detection.** You were affected if a command that worked before your build +stopped being recognized afterwards, in a session where nothing obviously +removed it: + + The term 'Get-Widget' is not recognized as a name of a cmdlet, function, + script file, or executable program. + +**If you worked around this by dropping the documentation tasks**, you can +put them back. `PowerShellOrg/PSDepend` did exactly that: + +**Before (0.8.x):** + +```powershell +# Skips BuildHelp (GenerateMarkdown) - Build-PSBuildMarkdown has a Remove-Module scope bug +$PSBBuildDependency = @('StageFiles') +``` + +**After (1.0.0):** + +```powershell +# The default is fine again; delete the override entirely +``` + +Two smaller notes. The restored module is a fresh import rather than +literally your original instance, so a `PSModuleInfo` reference you were +holding across the call goes stale — a much smaller problem than the +command disappearing, and the only way to keep `-Force` refreshing the +documented command surface. And the import no longer passes `-Global`: +PlatyPS resolves the module through the `PSModuleInfo` object it is handed, +never by name, so the import no longer reaches into your session state at +all. The generated markdown is byte-for-byte unchanged. + +Tracked in [#221](https://github.com/psake/PowerShellBuild/issues/221). + +### The `Pester` task no longer unloads a module it never imported + +The same defect in a worse form. On 0.8.x, `Test-PSBuildPester` imported +the module under test only when `-ImportModule` was passed, but removed it +by name **unconditionally**. `$PSBPreference.Test.ImportModule` defaults to +`$false`, and both task files forward it verbatim, so for every consumer who +had not turned it on, the `Pester` task imported nothing and then removed +whatever you happened to have loaded under that name. `Test` depends on +`Pester`, so `./build.ps1` and `./build.ps1 -Task Test` both did it. + +On 1.0.0 the function removes only the instance it imported, and restores +anything it displaced on the way in. When `-ImportModule` is not passed it +now touches your loaded modules not at all. + +**Detection** is the same as the entry above: a command that was available +before the build is not available after it. This one reaches more builds, +because a consumer who turned documentation generation off still runs tests. + +No build-file change is needed. If you set +`$PSBPreference.Test.ImportModule = $true` purely to make the removal +symmetrical, that is no longer a reason to keep it — but leaving it on is +harmless, and the module under test is still imported from the output +directory, still displacing a stale copy for the duration of the run. + +Tracked in [#222](https://github.com/psake/PowerShellBuild/issues/222). + ## Adding an entry (for PR contributors) Every breaking-change PR that lands in v1.0.0 must add an entry here for diff --git a/tests/Build-PSBuildHelp.tests.ps1 b/tests/Build-PSBuildHelp.tests.ps1 index 738fc9c..7cc5992 100644 --- a/tests/Build-PSBuildHelp.tests.ps1 +++ b/tests/Build-PSBuildHelp.tests.ps1 @@ -139,6 +139,105 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { } } + Context 'Build-PSBuildMarkdown and the modules the caller had loaded' { + + # psake/PowerShellBuild#221. The function imported the module it was documenting and + # then, in its finally block, called Remove-Module by name -- which removes every + # loaded module with that name, including a copy the caller loaded and this function + # never imported. Generating documentation therefore emptied the session it ran in. + # PowerShellOrg/PSDepend gave up the docs tasks entirely rather than live with it. + # + # These assert on the session rather than on the generated files, because the files + # were always correct; the session was the casualty. + + It 'leaves a module the caller loaded from the documented path loaded and callable' { + # The blunt case: the caller and the docs build point at the same module, so + # Import-Module -Force displaces the caller's instance on the way in. Restoring it + # is the half of the fix that scoping the removal alone does not cover. + $scenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'evictionsamepath' + $manifestPath = Join-Path -Path $scenario.ModulePath -ChildPath ( + '{0}.psd1' -f $scenario.ModuleName + ) + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMarkdown' + Parameter = New-PSBuildMarkdownParameter -Scenario $scenario + ProbeModuleName = $scenario.ModuleName + ProbeModuleManifest = $manifestPath + ProbeCommandName = 'Get-Widget' + ProbeCommandParameter = @{ Name = 'Sprocket' } + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 1 + $probe.ProbeResultBefore.Name | Should -Be 'Sprocket' + + $probe.LoadedAfter | Should -Be 1 + $probe.ProbeResultAfter.Name | Should -Be 'Sprocket' + } + + It 'leaves the caller''s module alone when the documented module exports nothing' { + # The zero-export path warns and returns without generating a single file, and + # `return` inside `try` still runs the `finally`. So the one case that produces no + # documentation at all used to be just as destructive as the case that does. + # The caller's copy is loaded from a different path here, which is what the + # name-based removal reached and a scoped removal does not. + $scenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'evictionnoexport' + $documentedManifestPath = Join-Path -Path $scenario.ModulePath -ChildPath ( + '{0}.psd1' -f $scenario.ModuleName + ) + (Get-Content -Path $documentedManifestPath -Raw) -replace + "(?s)FunctionsToExport = @\(.*?\)", 'FunctionsToExport = @()' | + Set-Content -Path $documentedManifestPath + + $callerModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictionnoexportcaller' + ) + $callerManifestPath = Join-Path -Path $callerModulePath -ChildPath ( + '{0}.psd1' -f $scenario.ModuleName + ) + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMarkdown' + Parameter = New-PSBuildMarkdownParameter -Scenario $scenario + ProbeModuleName = $scenario.ModuleName + ProbeModuleManifest = $callerManifestPath + ProbeCommandName = 'Get-Widget' + ProbeCommandParameter = @{ Name = 'Sprocket' } + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.Warning -join ' ' | Should -Match 'no commands' + $scenario.LocalePath | Should -Not -Exist + + $probe.LoadedBefore | Should -Be 1 + $probe.LoadedAfter | Should -Be 1 + $probe.ProbeResultAfter.Name | Should -Be 'Sprocket' + } + + It 'leaves nothing loaded when the caller had nothing loaded' { + # The other side of restoring: putting back only what was there, so a session that + # started clean does not end up holding the module the docs were generated from. + $scenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'evictionclean' + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMarkdown' + Parameter = New-PSBuildMarkdownParameter -Scenario $scenario + ProbeModuleName = $scenario.ModuleName + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 0 + $probe.LoadedAfter | Should -Be 0 + } + } + Context 'Build-PSBuildMAMLHelp' { BeforeAll { diff --git a/tests/Test-PSBuildPester.tests.ps1 b/tests/Test-PSBuildPester.tests.ps1 index 43ccb4f..3e30f46 100644 --- a/tests/Test-PSBuildPester.tests.ps1 +++ b/tests/Test-PSBuildPester.tests.ps1 @@ -302,6 +302,103 @@ Describe 'Coverage target' { $result.ErrorMessage | Should -BeNullOrEmpty } + It 'leaves a module the caller had loaded alone when ImportModule is not passed' { + # Regression: psake/PowerShellBuild#222. The import was conditional on + # -ImportModule; the Remove-Module in the finally block was not. ImportModule + # defaults to $false, so on the default Pester task the function imported nothing + # and then removed whatever the caller happened to have loaded under that name. + $callerModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictionnoimport' + ) + $callerManifestPath = Join-Path -Path $callerModulePath -ChildPath 'PSBuildTestFixture.psd1' + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Test-PSBuildPester' + Parameter = @{ + Path = $script:healthyPath + ModuleName = 'PSBuildTestFixture' + OutputVerbosity = 'None' + } + ProbeModuleName = 'PSBuildTestFixture' + ProbeModuleManifest = $callerManifestPath + ProbeCommandName = 'Get-Widget' + ProbeCommandParameter = @{ Name = 'Sprocket' } + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 1 + $probe.ProbeResultBefore.Name | Should -Be 'Sprocket' + + $probe.LoadedAfter | Should -Be 1 + $probe.ProbeResultAfter.Name | Should -Be 'Sprocket' + } + + It 'restores the caller''s module and removes its own after ImportModule' { + # The caller's copy and the copy under test are deliberately different paths, so + # the count distinguishes three outcomes: 0 means the caller was evicted, 2 means + # the function left its own import behind, 1 means it cleaned up exactly what it + # created and put the caller's session back. + $callerModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictioncaller' + ) + $callerManifestPath = Join-Path -Path $callerModulePath -ChildPath 'PSBuildTestFixture.psd1' + $testedModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictiontested' + ) + $testedManifestPath = Join-Path -Path $testedModulePath -ChildPath 'PSBuildTestFixture.psd1' + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Test-PSBuildPester' + Parameter = @{ + Path = $script:healthyPath + ModuleName = 'PSBuildTestFixture' + ModuleManifest = $testedManifestPath + ImportModule = $true + OutputVerbosity = 'None' + } + ProbeModuleName = 'PSBuildTestFixture' + ProbeModuleManifest = $callerManifestPath + ProbeCommandName = 'Get-Widget' + ProbeCommandParameter = @{ Name = 'Sprocket' } + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 1 + $probe.LoadedAfter | Should -Be 1 + $probe.ProbeResultAfter.Name | Should -Be 'Sprocket' + } + + It 'leaves nothing loaded when the caller had nothing loaded' { + # Restoring must put back only what was there. A session that started without the + # module must not end up holding the copy the test run imported. + $testedModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictionclean' + ) + $testedManifestPath = Join-Path -Path $testedModulePath -ChildPath 'PSBuildTestFixture.psd1' + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Test-PSBuildPester' + Parameter = @{ + Path = $script:healthyPath + ModuleName = 'PSBuildTestFixture' + ModuleManifest = $testedManifestPath + ImportModule = $true + OutputVerbosity = 'None' + } + ProbeModuleName = 'PSBuildTestFixture' + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 0 + $probe.LoadedAfter | Should -Be 0 + } + 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 diff --git a/tests/fixtures/FixtureHelpers.psm1 b/tests/fixtures/FixtureHelpers.psm1 index 75530b2..827af93 100644 --- a/tests/fixtures/FixtureHelpers.psm1 +++ b/tests/fixtures/FixtureHelpers.psm1 @@ -394,9 +394,190 @@ function Invoke-TestPSBuildPesterInJob { Invoke-PSBuildCommandInJob @jobParameter } +function Invoke-PSBuildModuleEvictionProbe { + <# + .SYNOPSIS + Run one PowerShellBuild command in a background job and report what it did to the + caller's loaded modules. + .DESCRIPTION + Loads a module the way a consumer would, invokes a PowerShellBuild command, and reports + what was loaded and callable on both sides of the call. That before-and-after pair is + the whole point: psake/PowerShellBuild#221 and #222 both left the command's own output + correct while emptying the session around it, so only a probe that looks at the session + can see the defect. + + The probe runs in a background job for the same reason Invoke-PSBuildCommandInJob does, + and then some -- an eviction is by definition a change to the session it runs in, so + measuring it in the test session would corrupt the test session. + + Supply no ProbeModuleManifest to measure the opposite case: with nothing loaded + beforehand, LoadedAfter says whether the command left something behind. + .PARAMETER ModulePath + Path to the built PowerShellBuild module to import inside the job. + .PARAMETER CommandName + Name of the PowerShellBuild command to invoke. + .PARAMETER Parameter + Parameters to splat onto the command. + .PARAMETER ProbeModuleName + Name of the module to count before and after the call. + .PARAMETER ProbeModuleManifest + Manifest of the module to import globally before the call, standing in for a module the + consumer had loaded. Omit it to start from a session with nothing loaded. + .PARAMETER ProbeCommandName + Command exported by the probe module to invoke before and after the call. A module can + be reported as loaded while its commands are no longer resolvable, so the count alone + is not proof the session survived. + .PARAMETER ProbeCommandParameter + Parameters to splat onto the probe command. + .PARAMETER TimeoutSecond + How long to wait before giving up. Defaults to 300. + .EXAMPLE + PS> $probe = Invoke-PSBuildModuleEvictionProbe -ModulePath $builtModulePath -CommandName 'Build-PSBuildMarkdown' -Parameter $parameter -ProbeModuleName 'PSBuildTestFixture' -ProbeModuleManifest $manifestPath -ProbeCommandName 'Get-Widget' -ProbeCommandParameter @{ Name = 'Sprocket' } + + Reports whether generating markdown left the caller's PSBuildTestFixture loaded and + Get-Widget callable. + .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, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ProbeModuleName, + + [string] + $ProbeModuleManifest, + + [string] + $ProbeCommandName, + + [ValidateNotNull()] + [hashtable] + $ProbeCommandParameter = @{}, + + [ValidateRange(1, 3600)] + [int] + $TimeoutSecond = 300 + ) + + $job = Start-Job -ScriptBlock { + param( + $modulePath, + $commandName, + $parameter, + $probeModuleName, + $probeModuleManifest, + $probeCommandName, + $probeCommandParameter + ) + + Import-Module -Name $modulePath -Force -ErrorAction Stop + + # Imported globally because that is how a consumer's session holds a module, and the + # global session state is the one the defect empties. + if ($probeModuleManifest) { + Import-Module -Name $probeModuleManifest -Force -Global -ErrorAction Stop + } + + # A failed probe reports as $null rather than an error, so a test asserts on the value + # it expected back instead of on an exception whose text says nothing about the module. + $probeScript = { + param($name, $probeParameter) + + if (-not $name) { + return $null + } + try { + & $name @probeParameter + } catch { + $null + } + } + + $loadedBefore = @(Get-Module -Name $probeModuleName).Count + $probeResultBefore = & $probeScript $probeCommandName $probeCommandParameter + + $invokeParameter = @{} + foreach ($key in $parameter.Keys) { + $invokeParameter[$key] = $parameter[$key] + } + if (-not $invokeParameter.ContainsKey('ErrorAction')) { + $invokeParameter['ErrorAction'] = 'Stop' + } + $commandWarning = @() + if (-not $invokeParameter.ContainsKey('WarningVariable')) { + $invokeParameter['WarningVariable'] = 'commandWarning' + } + + $threw = $false + $errorMessage = $null + try { + $null = & $commandName @invokeParameter + } catch { + $threw = $true + $errorMessage = $_.Exception.Message + } + + $loadedAfter = @(Get-Module -Name $probeModuleName).Count + $probeResultAfter = & $probeScript $probeCommandName $probeCommandParameter + + [PSCustomObject]@{ + Threw = $threw + ErrorMessage = $errorMessage + Warning = @($commandWarning.ForEach({ $_.ToString() })) + LoadedBefore = $loadedBefore + LoadedAfter = $loadedAfter + ProbeResultBefore = $probeResultBefore + ProbeResultAfter = $probeResultAfter + } + } -ArgumentList $ModulePath, $CommandName, $Parameter, $ProbeModuleName, $ProbeModuleManifest, $ProbeCommandName, $ProbeCommandParameter + + $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." + Warning = @() + LoadedBefore = -1 + LoadedAfter = -1 + ProbeResultBefore = $null + ProbeResultAfter = $null + } + } + + $jobResult = Receive-Job -Job $job + Remove-Job -Job $job -Force + $jobResult +} + + Export-ModuleMember -Function @( 'Copy-PSBuildTestFixture' 'Invoke-PSBuildCommandInJob' + 'Invoke-PSBuildModuleEvictionProbe' 'Invoke-TestPSBuildPesterInJob' 'New-PSBuildDocsScenario' 'New-PSBuildMarkdownParameter'