From b1a2299d7c0223544abbbc0025f849f0ef0d3269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Thu, 24 Sep 2026 20:46:27 +0200 Subject: [PATCH 1/2] Print a parallel worker's warnings once, not two or three times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #3044 `Invoke-InRunspacePool` creates the pool with the caller's `$Host`, so a worker's `Write-Warning` already reaches the console while the worker runs. The loop that re-emitted `Streams.Warning` after `EndInvoke` printed every warning a second time, and a third time for the workers that started first. Errors are different, they only land in `Streams.Error` and never reach the host on their own, so that re-emit stays. Added tests for both, the warning one fails without the fix. 🤖 --- src/functions/Pester.Parallel.ps1 | 10 ++++---- tst/Pester.RSpec.Parallel.ts.ps1 | 39 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/functions/Pester.Parallel.ps1 b/src/functions/Pester.Parallel.ps1 index 78f71b143..a01e33067 100644 --- a/src/functions/Pester.Parallel.ps1 +++ b/src/functions/Pester.Parallel.ps1 @@ -162,14 +162,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 { diff --git a/tst/Pester.RSpec.Parallel.ts.ps1 b/tst/Pester.RSpec.Parallel.ts.ps1 index ffd635079..168885e10 100644 --- a/tst/Pester.RSpec.Parallel.ts.ps1 +++ b/tst/Pester.RSpec.Parallel.ts.ps1 @@ -944,6 +944,45 @@ 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 "returns nothing for an empty input" { $r = & (Get-Module Pester) { Invoke-InRunspacePool -InputObject @() -ThrottleLimit 2 -ScriptBlock { param($item) $item } From fbb5ac5af650dffe7cf7a90d8f66a3c4b28f1508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Tue, 29 Sep 2026 19:59:49 +0200 Subject: [PATCH 2/2] Run a parallel worker with the caller's preference variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A worker runspace starts with every preference variable at its default, so it decided on its own what to print. A caller who had silenced warnings still got them, and a caller who 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. Invoke-Pester hands its caller's session state to Invoke-TestInParallel, which reads WarningPreference, VerbosePreference, DebugPreference, InformationPreference and ProgressPreference from it and defines them in every worker runspace. ErrorActionPreference is left out on purpose, it decides how a command behaves on error and not only what reaches the screen. Write-Host is unaffected either way, it is not preference-controlled and already printed once in both modes. 🤖 --- src/Main.ps1 | 2 +- src/functions/Pester.Parallel.ps1 | 57 ++++++++++++++++++++++++++-- tst/Pester.RSpec.Parallel.ts.ps1 | 63 +++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/Main.ps1 b/src/Main.ps1 index 808b85de9..7f84e6545 100644 --- a/src/Main.ps1 +++ b/src/Main.ps1 @@ -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 diff --git a/src/functions/Pester.Parallel.ps1 b/src/functions/Pester.Parallel.ps1 index a01e33067..924bebebd 100644 --- a/src/functions/Pester.Parallel.ps1 +++ b/src/functions/Pester.Parallel.ps1 @@ -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) @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/tst/Pester.RSpec.Parallel.ts.ps1 b/tst/Pester.RSpec.Parallel.ts.ps1 index 168885e10..f3cc7a7ca 100644 --- a/tst/Pester.RSpec.Parallel.ts.ps1 +++ b/tst/Pester.RSpec.Parallel.ts.ps1 @@ -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 @@ -983,6 +984,26 @@ BeforeAll { } (($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 } @@ -1310,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 = '' + $c.Run.Parallel = $true + $c.Output.RenderMode = 'Plaintext' + Invoke-Pester -Configuration $c + } + $sb = [scriptblock]::Create($run.ToString().Replace('', $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