Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Main.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,7 @@ function Invoke-Pester {
# file's discovery segment then run segment, in discovery order, firing the global
# RunStart/DiscoveryEnd steps at the interleaved points a sequential run would.
if (0 -lt $parallelContainers.Count) {
$parallelResults = @(Invoke-TestInParallel -BlockContainer $parallelContainers -Configuration $PesterPreference)
$parallelResults = @(Invoke-TestInParallel -BlockContainer $parallelContainers -Configuration $PesterPreference -CallerSessionState $sessionState)
for ($pri = 0; $pri -lt $parallelResults.Count; $pri++) {
$parallelResult = $parallelResults[$pri]
$segments = Split-PesterEventTape -Tape $parallelResult.Tape
Expand Down
67 changes: 59 additions & 8 deletions src/functions/Pester.Parallel.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ function Invoke-InRunspacePool {
[System.Collections.IDictionary] $Parameters = @{},

# Name of the worker parameter that receives the current input item.
[string] $ItemParameterName = 'item'
[string] $ItemParameterName = 'item',

# Variables to define in every worker runspace, before the scriptblock runs. Used to carry
# the caller's preference variables in, see Invoke-TestInParallel.
[System.Collections.IDictionary] $Variable = @{}
)

$items = @($InputObject)
Expand All @@ -123,6 +127,14 @@ function Invoke-InRunspacePool {
if ($ThrottleLimit -lt 1) { $ThrottleLimit = 1 }

$sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
# A fresh runspace starts with every preference variable at its default, so without this the
# worker would decide on its own what to print, no matter what the caller asked for. Define
# them in the worker's global scope, which is where a sequential run resolves them from too.
foreach ($key in $Variable.Keys) {
$sessionState.Variables.Add(
[System.Management.Automation.Runspaces.SessionStateVariableEntry]::new($key, $Variable[$key], ''))
}

# Share the host, the way ForEach-Object -Parallel does, so a worker that does write to the
# console reaches the same one. Pester's workers are silenced, this is for anything else.
$pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $ThrottleLimit, $sessionState, $Host)
Expand Down Expand Up @@ -162,14 +174,14 @@ function Invoke-InRunspacePool {
& $SafeCommands['Write-Error'] -ErrorRecord $_ -ErrorAction Continue
}

# The worker has no console of its own, so anything it wrote to these streams would be
# lost. Re-emit it here, non-terminating for the same reason as above.
# Only the error stream needs re-emitting. The pool shares the caller's host (see
# above), so a worker's warning, verbose and Write-Host output already reached the
# console live, while its errors only landed in Streams.Error. Re-emitting the
# warning stream as well printed every warning a second time, which is why this loop
# covers the error stream alone. Non-terminating for the same reason as above.
foreach ($errorRecord in $invocation.PowerShell.Streams.Error) {
& $SafeCommands['Write-Error'] -ErrorRecord $errorRecord -ErrorAction Continue
}
foreach ($warningRecord in $invocation.PowerShell.Streams.Warning) {
& $SafeCommands['Write-Warning'] -Message $warningRecord.Message
}
}
}
finally {
Expand All @@ -181,6 +193,41 @@ function Invoke-InRunspacePool {
}
}

function Get-CallerPreferenceVariable {
<#
.SYNOPSIS
Reads the output preference variables the caller of Invoke-Pester was running with.

.DESCRIPTION
A worker runspace starts with every preference variable at its default, so on its own it
prints what the defaults say and not what the caller asked for. A run where the caller had
silenced warnings still printed them, and one where the caller had asked for verbose output
got none. Both differ from what the same files print in a sequential run, where the test body
resolves these variables from the caller's scope.

Only the preferences that decide what is printed are read. ErrorActionPreference is left out
on purpose, it decides how a command behaves on error and not only what reaches the screen.
#>
[CmdletBinding()]
param(
[System.Management.Automation.SessionState] $SessionState
)

$preferences = @{}
if ($null -eq $SessionState) {
return $preferences
}

foreach ($name in 'WarningPreference', 'VerbosePreference', 'DebugPreference', 'InformationPreference', 'ProgressPreference') {
$value = $SessionState.PSVariable.GetValue($name)
if ($null -ne $value) {
$preferences[$name] = $value
}
}

$preferences
}

function Invoke-TestInParallel {
<#
.SYNOPSIS
Expand Down Expand Up @@ -227,7 +274,11 @@ function Invoke-TestInParallel {
[Pester.ContainerInfo[]] $BlockContainer,

[Parameter(Mandatory)]
$Configuration
$Configuration,

# The caller's session state, captured by Invoke-Pester. Used to read the preference
# variables the caller was running with, so the workers print what a sequential run prints.
[System.Management.Automation.SessionState] $CallerSessionState
)

# Which Pester.BeforeContainer.ps1 files apply depends on where the file sits, from
Expand Down Expand Up @@ -423,7 +474,7 @@ function Invoke-TestInParallel {

$results = @()
if (0 -lt $work.Count) {
$results = Invoke-InRunspacePool -InputObject $work -ScriptBlock $worker -ThrottleLimit $throttle -Parameters @{
$results = Invoke-InRunspacePool -InputObject $work -ScriptBlock $worker -ThrottleLimit $throttle -Variable (Get-CallerPreferenceVariable -SessionState $CallerSessionState) -Parameters @{
modulePath = $modulePath
baseConfig = $baseConfig
recordedSteps = $recordedSteps
Expand Down
102 changes: 102 additions & 0 deletions tst/Pester.RSpec.Parallel.ts.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Get-Module P, PTestHelpers, Pester, Axiom | Remove-Module

Import-Module $PSScriptRoot\p.psm1 -DisableNameChecking
Import-Module $PSScriptRoot\axiom\Axiom.psm1 -DisableNameChecking
Import-Module $PSScriptRoot\PTestHelpers.psm1 -DisableNameChecking

if (-not $NoBuild) { & "$PSScriptRoot\..\build.ps1" }
Import-Module $PSScriptRoot\..\bin\Pester.psd1
Expand Down Expand Up @@ -944,6 +945,65 @@ BeforeAll { }
($errors.Count -gt 0) | Verify-True
}

t "does not re-emit a worker's warnings, the shared host already showed them (#3044)" {
# The pool shares the caller's host, so a worker's Write-Warning reaches the console
# live. Re-emitting Streams.Warning on top of that printed every warning a second time.
# The live copy goes straight to the host and never touches this pipeline's warning
# stream, so anything 3>&1 captures here is a re-emitted duplicate.
$out = & (Get-Module Pester) {
Invoke-InRunspacePool -InputObject @(1, 2) -ThrottleLimit 2 -ScriptBlock {
param($item)
Write-Warning "worker-warning-$item"
$item
}
} 3>&1

$warnings = @($out | Where-Object { $_ -is [System.Management.Automation.WarningRecord] })
$values = @($out | Where-Object { $_ -isnot [System.Management.Automation.WarningRecord] })

($values | Sort-Object) -join ',' | Verify-Equal '1,2'
$warnings.Count | Verify-Equal 0
}

t "still re-emits a worker's errors, those never reach the host on their own" {
# The counterpart to the test above. Unlike warnings, a worker's non-terminating errors
# only land in Streams.Error, so dropping this re-emit would lose them entirely.
$out = & (Get-Module Pester) {
Invoke-InRunspacePool -InputObject @(1, 2) -ThrottleLimit 2 -ScriptBlock {
param($item)
Write-Error -Message "worker-error-$item" -ErrorAction Continue
$item
}
} 2>&1

$errors = @($out | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] })
$values = @($out | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] })

($values | Sort-Object) -join ',' | Verify-Equal '1,2'
$errors.Count | Verify-Equal 2
(($errors | Sort-Object { "$_" }) -join ',') | Verify-Equal 'worker-error-1,worker-error-2'
}

t "defines the given variables in every worker runspace" {
$r = & (Get-Module Pester) {
Invoke-InRunspacePool -InputObject @(1, 2) -ThrottleLimit 2 -Variable @{ WarningPreference = 'SilentlyContinue' } -ScriptBlock {
param($item)
"$item-$WarningPreference"
}
}
(@($r) | Sort-Object) -join ',' | Verify-Equal '1-SilentlyContinue,2-SilentlyContinue'
}

t "leaves the worker on its defaults when no variables are given" {
$r = & (Get-Module Pester) {
Invoke-InRunspacePool -InputObject @(1) -ThrottleLimit 1 -ScriptBlock {
param($item)
"$WarningPreference"
}
}
@($r)[0] | Verify-Equal 'Continue'
}

t "returns nothing for an empty input" {
$r = & (Get-Module Pester) {
Invoke-InRunspacePool -InputObject @() -ThrottleLimit 2 -ScriptBlock { param($item) $item }
Expand Down Expand Up @@ -1271,6 +1331,48 @@ Describe 'OuterTwo' {
}
}

b "Run.Parallel preference variables" {
t "a worker prints what a sequential run prints, not what its own defaults say (#3044)" {
# A worker runspace starts with every preference variable at its default, so on its own it
# printed a warning the caller had silenced and swallowed the verbose output the caller had
# asked for. Both differ from a sequential run of the same files. The worker writes to the
# shared host directly, which no redirection in this process can capture, so run it in a
# child process and read its console output.
$folder = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().Guid)
$null = New-Item -ItemType Directory -Path $folder -Force
try {
Set-Content -Path (Join-Path $folder 'A.Tests.ps1') -Value @'
Describe 'A' {
It 'writes to both streams' {
Write-Warning 'warning-from-test'
Write-Verbose 'verbose-from-test'
1 | Should -Be 1
}
}
'@
$run = {
$WarningPreference = 'SilentlyContinue'
$VerbosePreference = 'Continue'
$c = [PesterConfiguration]::Default
$c.Run.Path = '<folder>'
$c.Run.Parallel = $true
$c.Output.RenderMode = 'Plaintext'
Invoke-Pester -Configuration $c
}
$sb = [scriptblock]::Create($run.ToString().Replace('<folder>', $folder))
$output = @(Invoke-InNewProcess -ScriptBlock $sb) -join "`n"

# The caller silenced warnings, so the worker must not print one.
($output -match 'warning-from-test') | Verify-False
# The caller asked for verbose, so the worker must print it.
($output -match 'verbose-from-test') | Verify-True
}
finally {
Remove-Item -Path $folder -Recurse -Force -ErrorAction Ignore
}
}
}

b "Run.Parallel debug output" {
t "captures debug output and replays it interleaved with each file's tests" {
# Each worker writes nothing to the host directly and records its screen and debug output into
Expand Down
Loading