From e96c6df26fc9043ecc79826977c892d98da8a51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Sat, 22 Aug 2026 09:56:20 +0200 Subject: [PATCH 1/3] Run test files in parallel on Windows PowerShell 5.1 too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run.Parallel was built on ForEach-Object -Parallel, which only exists on PowerShell 7, so on Windows PowerShell 5.1 it warned and ran sequentially. 5.1 is the slowest leg in our own matrix and it is the one that sets how long a build takes, so it is the edition that needs this most. Both editions now go through Invoke-InRunspacePool, a runspace pool built on the API that 5.1 and 7 both have. One code path, so the two editions cannot drift. The worker scriptblock takes its values as named parameters instead of $using:, which is a ForEach-Object -Parallel feature, and it is handed over as text and re-parsed in the worker so nothing with runspace affinity crosses. The runspaces are in this process either way, so the values still cross as live objects. A pooled runspace starts at the process working directory rather than the caller's location, which ForEach-Object -Parallel preserves, so the worker sets it from the parent. The tests that were asserting parallel behaviour only on 7+ now run on both, so 5.1 is covered by the same assertions and not by a fallback message. Added tests for the pool itself: concurrency, throttle, parameter passing, one worker throwing without taking the rest down, and empty input. No new dependency, the implementation is in the module. 🤖 --- src/Main.ps1 | 18 +- src/csharp/Pester/RunConfiguration.cs | 4 +- src/en-US/about_PesterConfiguration.help.txt | 4 +- src/functions/Pester.Parallel.ps1 | 128 +++++++++++-- tst/Pester.RSpec.Parallel.ts.ps1 | 184 ++++++++++++------- 5 files changed, 249 insertions(+), 89 deletions(-) diff --git a/src/Main.ps1 b/src/Main.ps1 index a50d4df9d..5ea02dd53 100644 --- a/src/Main.ps1 +++ b/src/Main.ps1 @@ -645,12 +645,11 @@ function Invoke-Pester { } # Parallel mode runs each file in its own runspace and merges the executed - # containers back. It only applies to file-based runs on PowerShell 7+; other cases fall - # back to the normal sequential path with a warning. CodeCoverage is supported: each - # worker measures its own file with breakpoints and the parent merges the results (see - # the parallel branch below). + # containers back. It applies to file-based runs on both Windows PowerShell 5.1 and + # PowerShell 7; other cases fall back to the normal sequential path with a warning. + # CodeCoverage is supported: each worker measures its own file with breakpoints and the + # parent merges the results (see the parallel branch below). $useParallel = $PesterPreference.Run.Parallel.Value - $parallelSupported = $PSVersionTable.PSVersion.Major -ge 7 $allFileContainers = 0 -eq @($containers | & $SafeCommands['Where-Object'] { 'File' -ne $_.Type }).Count $coverageEnabled = $PesterPreference.CodeCoverage.Enabled.Value # Run.SkipRemainingOnFailure = 'Run' stops the whole run after the first failed @@ -670,7 +669,7 @@ function Invoke-Pester { # them inherits those type constraints, which silently corrupts the loop variable. $parallelContainers = [System.Collections.Generic.List[object]]@() $nonParallelContainers = [System.Collections.Generic.List[object]]@() - if ($useParallel -and $parallelSupported -and $allFileContainers -and -not $skipRemainingRunScope) { + if ($useParallel -and $allFileContainers -and -not $skipRemainingRunScope) { foreach ($fileContainer in $containers) { if (Test-PesterFileIsNonParallel -Path $fileContainer.Item.FullName) { $nonParallelContainers.Add($fileContainer) @@ -681,10 +680,7 @@ function Invoke-Pester { } } - if ($useParallel -and -not $parallelSupported) { - & $SafeCommands['Write-Warning'] "Run.Parallel requires PowerShell 7 or later for 'ForEach-Object -Parallel'. Running the tests sequentially instead." - } - elseif ($useParallel -and -not $allFileContainers) { + if ($useParallel -and -not $allFileContainers) { & $SafeCommands['Write-Warning'] "Run.Parallel currently parallelizes only file-based runs (Run.Path). The provided ScriptBlock/Container test(s) will run sequentially instead." } elseif ($useParallel -and $skipRemainingRunScope) { @@ -700,7 +696,7 @@ function Invoke-Pester { # If every file opted out with #pester:no-parallel, the run is effectively sequential, # so fall through to the sequential path, which fires the framework's own global plugin # steps at the correct interleaved points. - $ranInParallel = $useParallel -and $parallelSupported -and $allFileContainers -and -not $skipRemainingRunScope -and 0 -lt $parallelContainers.Count + $ranInParallel = $useParallel -and $allFileContainers -and -not $skipRemainingRunScope -and 0 -lt $parallelContainers.Count if ($ranInParallel) { $foldedContainers = [System.Collections.Generic.List[object]]@() $hasNonParallel = 0 -lt $nonParallelContainers.Count diff --git a/src/csharp/Pester/RunConfiguration.cs b/src/csharp/Pester/RunConfiguration.cs index 2e92ec333..4ce115f86 100644 --- a/src/csharp/Pester/RunConfiguration.cs +++ b/src/csharp/Pester/RunConfiguration.cs @@ -80,8 +80,8 @@ public RunConfiguration(IDictionary configuration) : this() Throw = new BoolOption("Throw an exception when test run fails. When used together with Exit, throwing an exception is preferred.", false); PassThru = new BoolOption("Return result object to the pipeline after finishing the test run.", false); SkipRun = new BoolOption("Runs the discovery phase but skips run. Use it with PassThru to get object populated with all tests.", false); - Parallel = new BoolOption("EXPERIMENTAL: Run test files in parallel, each file in its own runspace, using PowerShell 7+ 'ForEach-Object -Parallel'. Files that contain the '#pester:no-parallel' directive run sequentially after the parallel batch. Falls back to a sequential run on Windows PowerShell 5.1, when non-file containers (ScriptBlock) are used, when Run.SkipRemainingOnFailure is set to 'Run', or when every file opts out of parallel. CodeCoverage is supported: each worker measures its own file and Pester merges the results into a single report.", false); - ParallelThrottleLimit = new IntOption("EXPERIMENTAL: Maximum number of test files to run at the same time when Run.Parallel is enabled, passed through to 'ForEach-Object -Parallel -ThrottleLimit'. The default 0 uses all available processors ([Environment]::ProcessorCount). Set a lower number to cap how many runspaces run concurrently. Only used when Run.Parallel is enabled.", 0); + Parallel = new BoolOption("EXPERIMENTAL: Run test files in parallel, each file in its own runspace, on both Windows PowerShell 5.1 and PowerShell 7. Files that contain the '#pester:no-parallel' directive run sequentially after the parallel batch. Falls back to a sequential run when non-file containers (ScriptBlock) are used, when Run.SkipRemainingOnFailure is set to 'Run', or when every file opts out of parallel. CodeCoverage is supported: each worker measures its own file and Pester merges the results into a single report.", false); + ParallelThrottleLimit = new IntOption("EXPERIMENTAL: Maximum number of test files to run at the same time when Run.Parallel is enabled. The default 0 uses all available processors ([Environment]::ProcessorCount). Set a lower number to cap how many runspaces run concurrently. Only used when Run.Parallel is enabled.", 0); SkipRemainingOnFailure = new StringOption("Skips remaining tests after failure for selected scope, options are None, Run, Container and Block.", "None"); FailOnNullOrEmptyForEach = new BoolOption("Fails discovery when -ForEach is provided $null or @() in a block or test. Can be overridden for a specific Describe/Context/It using -AllowNullOrEmptyForEach.", true); Shuffle = new BoolOption("EXPERIMENTAL: Shuffle the order in which test files, and the blocks (Describe/Context) and tests (It) inside them, are executed. Items are only reordered within their own level. Uses Run.ShuffleSeed so a run can be repeated, and helps surface hidden dependencies between tests. A single file can opt out with a '#pester:no-shuffle' comment.", false); diff --git a/src/en-US/about_PesterConfiguration.help.txt b/src/en-US/about_PesterConfiguration.help.txt index 40d8d4928..728ec5df3 100644 --- a/src/en-US/about_PesterConfiguration.help.txt +++ b/src/en-US/about_PesterConfiguration.help.txt @@ -65,11 +65,11 @@ SECTIONS AND OPTIONS Type: bool Default value: $false - Parallel: EXPERIMENTAL: Run test files in parallel, each file in its own runspace, using PowerShell 7+ 'ForEach-Object -Parallel'. Files that contain the '#pester:no-parallel' directive run sequentially after the parallel batch. Falls back to a sequential run on Windows PowerShell 5.1, when non-file containers (ScriptBlock) are used, when Run.SkipRemainingOnFailure is set to 'Run', or when every file opts out of parallel. CodeCoverage is supported: each worker measures its own file and Pester merges the results into a single report. + Parallel: EXPERIMENTAL: Run test files in parallel, each file in its own runspace, on both Windows PowerShell 5.1 and PowerShell 7. Files that contain the '#pester:no-parallel' directive run sequentially after the parallel batch. Falls back to a sequential run when non-file containers (ScriptBlock) are used, when Run.SkipRemainingOnFailure is set to 'Run', or when every file opts out of parallel. CodeCoverage is supported: each worker measures its own file and Pester merges the results into a single report. Type: bool Default value: $false - ParallelThrottleLimit: EXPERIMENTAL: Maximum number of test files to run at the same time when Run.Parallel is enabled, passed through to 'ForEach-Object -Parallel -ThrottleLimit'. The default 0 uses all available processors ([Environment]::ProcessorCount). Set a lower number to cap how many runspaces run concurrently. Only used when Run.Parallel is enabled. + ParallelThrottleLimit: EXPERIMENTAL: Maximum number of test files to run at the same time when Run.Parallel is enabled. The default 0 uses all available processors ([Environment]::ProcessorCount). Set a lower number to cap how many runspaces run concurrently. Only used when Run.Parallel is enabled. Type: int Default value: 0 diff --git a/src/functions/Pester.Parallel.ps1 b/src/functions/Pester.Parallel.ps1 index 56b55e2ee..91750d633 100644 --- a/src/functions/Pester.Parallel.ps1 +++ b/src/functions/Pester.Parallel.ps1 @@ -1,4 +1,4 @@ -function Test-PesterFileIsNonParallel { +function Test-PesterFileIsNonParallel { <# .SYNOPSIS Returns $true when a test file opts out of parallel execution via a file-level directive. @@ -119,6 +119,102 @@ function Split-PesterEventTape { } } +function Invoke-InRunspacePool { + <# + .SYNOPSIS + Runs a scriptblock once per input item, concurrently, in a pool of runspaces. + + .DESCRIPTION + The parallelism primitive behind Run.Parallel. `ForEach-Object -Parallel` does the same thing, + but it only exists on PowerShell 7 and Pester also supports Windows PowerShell 5.1, so this is + built on the runspace API that both editions have. One implementation for both keeps the two + editions from drifting apart. + + The scriptblock is handed over as text and re-parsed inside the worker runspace, so nothing + with runspace affinity crosses the boundary. Values reach the worker as named parameters + rather than through `$using:`, which is a ForEach-Object -Parallel feature. The runspaces live + in this process, so the values themselves cross as live objects either way. + + Each item gets its own PowerShell instance and they are all started at once; the pool is what + limits how many actually run, so ThrottleLimit means the same as it does on ForEach-Object. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('Pester.BuildAnalyzerRules\Measure-SafeCommands', '', Justification = 'Runspace and PowerShell API calls, not cmdlets.')] + [CmdletBinding()] + param( + [object[]] $InputObject, + + [Parameter(Mandatory)] + [scriptblock] $ScriptBlock, + + [int] $ThrottleLimit = 1, + + # Passed to every worker as named parameters, on top of the item itself. + [System.Collections.IDictionary] $Parameters = @{}, + + # Name of the worker parameter that receives the current input item. + [string] $ItemParameterName = 'item' + ) + + $items = @($InputObject) + if (0 -eq $items.Count) { + return + } + + if ($ThrottleLimit -lt 1) { $ThrottleLimit = 1 } + + $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + # 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) + $pool.ThreadOptions = [System.Management.Automation.Runspaces.PSThreadOptions]::ReuseThread + $pool.Open() + + $invocations = [System.Collections.Generic.List[object]]@() + try { + foreach ($item in $items) { + $powershell = [System.Management.Automation.PowerShell]::Create() + $powershell.RunspacePool = $pool + $null = $powershell.AddScript($ScriptBlock.ToString()) + $null = $powershell.AddParameter($ItemParameterName, $item) + foreach ($key in $Parameters.Keys) { + $null = $powershell.AddParameter($key, $Parameters[$key]) + } + + $invocations.Add([PSCustomObject]@{ + PowerShell = $powershell + Handle = $powershell.BeginInvoke() + }) + } + + foreach ($invocation in $invocations) { + try { + $invocation.PowerShell.EndInvoke($invocation.Handle) + } + catch { + # One worker failing must not take the rest of the run with it, the remaining files + # still have results worth reporting. Surface it and keep going. + & $SafeCommands['Write-Error'] -ErrorRecord $_ + } + + # The worker has no console of its own, so anything it wrote to these streams would be + # lost. Re-emit it here. + foreach ($errorRecord in $invocation.PowerShell.Streams.Error) { + & $SafeCommands['Write-Error'] -ErrorRecord $errorRecord + } + foreach ($warningRecord in $invocation.PowerShell.Streams.Warning) { + & $SafeCommands['Write-Warning'] -Message $warningRecord.Message + } + } + } + finally { + foreach ($invocation in $invocations) { + $invocation.PowerShell.Dispose() + } + $pool.Close() + $pool.Dispose() + } +} + function Invoke-TestInParallel { <# .SYNOPSIS @@ -127,8 +223,9 @@ function Invoke-TestInParallel { that fired while it ran. .DESCRIPTION - Used by Invoke-Pester when Run.Parallel is enabled on PowerShell 7+. Each test file is - executed by a full Invoke-Pester run inside its own runspace via `ForEach-Object -Parallel`. + Used by Invoke-Pester when Run.Parallel is enabled. Each test file is executed by a full + Invoke-Pester run inside its own runspace, from a pool (see Invoke-InRunspacePool), which + works the same on Windows PowerShell 5.1 and on PowerShell 7. The worker runs silently (Output.Verbosity = None) so it produces no console output of its own; instead it records every per-container and per-test plugin step (with the live Block / Test / Result objects) into an ordered tape. The parent replays that tape to its reporting @@ -136,7 +233,7 @@ function Invoke-TestInParallel { sequential run - only the concurrency differs. Because Pester.dll is loaded once per process (via Add-Type -Path) and shared by every - runspace, the [Pester.Container] objects and the recorded contexts are live objects - no + runspace in it, the [Pester.Container] objects and the recorded contexts are live objects - no serialization happens - so they can be folded straight back into a single run and replayed. The execution-critical plugins (Mock, TestDrive, TestRegistry, SkipRemainingOnFailure) run inside the worker where the test bodies execute; only the reporting plugins are replayed by @@ -156,7 +253,7 @@ function Invoke-TestInParallel { report generation and file write via the CodeCoverageSkipReport module flag. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('Pester.BuildAnalyzerRules\Measure-SafeCommands', '', Justification = 'Get-Module/Import-Module run in a fresh ForEach-Object -Parallel runspace where the module-internal $SafeCommands table is unavailable.')] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('Pester.BuildAnalyzerRules\Measure-ObjectCmdlets', '', Justification = 'ForEach-Object -Parallel is the runspace-parallelism primitive with no language-keyword equivalent; the accompanying Where-Object/Sort-Object run once over the small per-run result set.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('Pester.BuildAnalyzerRules\Measure-ObjectCmdlets', '', Justification = 'Where-Object/Sort-Object run once over the small per-run result set.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Recorder factory parameters are used inside the returned closure, which the rule does not follow.')] [CmdletBinding()] param( @@ -231,11 +328,14 @@ function Invoke-TestInParallel { # every worker's hits and emit one report. A recorder plugin is injected (via the supported # $script:additionalPlugins channel) to capture the ordered plugin-event tape returned for replay. $worker = { - $item = $_ - $modulePath = $using:modulePath - $baseConfig = $using:baseConfig - $recordedSteps = $using:recordedSteps - $collectCoverage = $using:collectCoverage + param($item, $modulePath, $baseConfig, $recordedSteps, $collectCoverage, $workingDirectory) + + # A fresh runspace starts at the process working directory, which is not necessarily where + # the caller is. ForEach-Object -Parallel keeps the caller's location and test files resolve + # relative paths against it, so put the worker there too. + if (-not [string]::IsNullOrEmpty($workingDirectory)) { + Set-Location -LiteralPath $workingDirectory + } if (-not (Get-Module -Name Pester)) { Import-Module $modulePath @@ -360,7 +460,13 @@ function Invoke-TestInParallel { $results = @() if (0 -lt $work.Count) { - $results = $work | & $SafeCommands['ForEach-Object'] -ThrottleLimit $throttle -Parallel $worker + $results = Invoke-InRunspacePool -InputObject $work -ScriptBlock $worker -ThrottleLimit $throttle -Parameters @{ + modulePath = $modulePath + baseConfig = $baseConfig + recordedSteps = $recordedSteps + collectCoverage = $collectCoverage + workingDirectory = $ExecutionContext.SessionState.Path.CurrentFileSystemLocation.Path + } } # Keep only well-formed worker results (defensive against stray pipeline output). diff --git a/tst/Pester.RSpec.Parallel.ts.ps1 b/tst/Pester.RSpec.Parallel.ts.ps1 index e9719ce05..01155a519 100644 --- a/tst/Pester.RSpec.Parallel.ts.ps1 +++ b/tst/Pester.RSpec.Parallel.ts.ps1 @@ -1,4 +1,4 @@ -param ([switch] $PassThru, [switch] $NoBuild) +param ([switch] $PassThru, [switch] $NoBuild) Get-Module P, PTestHelpers, Pester, Axiom | Remove-Module @@ -79,16 +79,6 @@ Describe 'Marker' { $folder } -function Get-ExpectedParallelFallbackWarning { - # Windows PowerShell 5.1 has no 'ForEach-Object -Parallel', so every Run.Parallel run trips the - # PowerShell-version gate and falls back to sequential before any feature-specific check - # (ScriptBlock / CodeCoverage / SkipRemainingOnFailure) is reached. The run still produces - # identical results either way - only the warning text differs - so tests assert the - # feature-specific text on PowerShell 7+ and the version text on 5.1. - param ([Parameter(Mandatory)] [string] $Ps7Pattern) - if ($PSVersionTable.PSVersion.Major -ge 7) { $Ps7Pattern } else { '*requires PowerShell 7*' } -} - i -PassThru:$PassThru { b "Run.Parallel configuration option" { t "exists and defaults to disabled" { @@ -134,33 +124,31 @@ Describe 'Slow2' { $r = Invoke-Pester -Configuration $c $sw.Stop() - if ($PSVersionTable.PSVersion.Major -ge 7) { - # Naive sum of the overlapping container durations - the old (wrong) run total. - $containerSum = [TimeSpan]::Zero - foreach ($container in $r.Containers) { $containerSum += $container.Duration } - - # Run total is the measured wall-clock: positive, never larger than the elapsed - # time around the whole call, and well below the naive sum because the files overlap. - ($r.Duration -gt [TimeSpan]::Zero) | Verify-True - ($r.Duration -le $sw.Elapsed) | Verify-True - ($r.Duration -lt $containerSum) | Verify-True - - # The per-phase run totals are blanked - a single wall-clock figure for user, - # framework or discovery time is not meaningful once the files overlap. - ($r.UserDuration -eq [TimeSpan]::Zero) | Verify-True - ($r.FrameworkDuration -eq [TimeSpan]::Zero) | Verify-True - ($r.DiscoveryDuration -eq [TimeSpan]::Zero) | Verify-True - - # Parallelism is file-level, so each container keeps its full duration breakdown. - foreach ($container in $r.Containers) { - ($container.Duration -gt [TimeSpan]::Zero) | Verify-True - ($container.UserDuration -gt [TimeSpan]::Zero) | Verify-True - } - # Discovery is measured per container too (summed here only to avoid per-file flakiness). - $discoverySum = [TimeSpan]::Zero - foreach ($container in $r.Containers) { $discoverySum += $container.DiscoveryDuration } - ($discoverySum -gt [TimeSpan]::Zero) | Verify-True + # Naive sum of the overlapping container durations - the old (wrong) run total. + $containerSum = [TimeSpan]::Zero + foreach ($container in $r.Containers) { $containerSum += $container.Duration } + + # Run total is the measured wall-clock: positive, never larger than the elapsed + # time around the whole call, and well below the naive sum because the files overlap. + ($r.Duration -gt [TimeSpan]::Zero) | Verify-True + ($r.Duration -le $sw.Elapsed) | Verify-True + ($r.Duration -lt $containerSum) | Verify-True + + # The per-phase run totals are blanked - a single wall-clock figure for user, + # framework or discovery time is not meaningful once the files overlap. + ($r.UserDuration -eq [TimeSpan]::Zero) | Verify-True + ($r.FrameworkDuration -eq [TimeSpan]::Zero) | Verify-True + ($r.DiscoveryDuration -eq [TimeSpan]::Zero) | Verify-True + + # Parallelism is file-level, so each container keeps its full duration breakdown. + foreach ($container in $r.Containers) { + ($container.Duration -gt [TimeSpan]::Zero) | Verify-True + ($container.UserDuration -gt [TimeSpan]::Zero) | Verify-True } + # Discovery is measured per container too (summed here only to avoid per-file flakiness). + $discoverySum = [TimeSpan]::Zero + foreach ($container in $r.Containers) { $discoverySum += $container.DiscoveryDuration } + ($discoverySum -gt [TimeSpan]::Zero) | Verify-True } finally { Remove-Item -Path $folder -Recurse -Force } } @@ -329,6 +317,80 @@ Describe 'Second' { } } + b "Invoke-InRunspacePool" { + # The parallelism primitive Run.Parallel is built on. It replaces ForEach-Object -Parallel, + # which does not exist on Windows PowerShell 5.1, so these run on both editions and are the + # place a difference between them would show up first. + + t "runs the items concurrently" { + # Four items that each sleep half a second take about 2s one after another. With four + # runspaces they overlap, so anything close to the sequential total means the pool is + # not actually running them at the same time. + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $r = & (Get-Module Pester) { + Invoke-InRunspacePool -InputObject @(1, 2, 3, 4) -ThrottleLimit 4 -ScriptBlock { + param($item) + Start-Sleep -Milliseconds 500 + $item + } + } + $sw.Stop() + + @($r).Count | Verify-Equal 4 + (@($r) | Sort-Object) -join ',' | Verify-Equal '1,2,3,4' + ($sw.Elapsed.TotalMilliseconds -lt 1500) | Verify-True + } + + t "honours the throttle limit" { + # Same four half-second items through a single runspace cannot take less than 2s. + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $null = & (Get-Module Pester) { + Invoke-InRunspacePool -InputObject @(1, 2, 3, 4) -ThrottleLimit 1 -ScriptBlock { + param($item) + Start-Sleep -Milliseconds 500 + $item + } + } + $sw.Stop() + ($sw.Elapsed.TotalMilliseconds -ge 2000) | Verify-True + } + + t "passes the item and the shared parameters to every worker" { + $r = & (Get-Module Pester) { + Invoke-InRunspacePool -InputObject @('a', 'b') -ThrottleLimit 2 -Parameters @{ prefix = 'p' } -ScriptBlock { + param($item, $prefix) + "$prefix-$item" + } + } + (@($r) | Sort-Object) -join ',' | Verify-Equal 'p-a,p-b' + } + + t "keeps going when one worker throws" { + # A failing file must not take the rest of the run with it, the others still have + # results worth reporting. + $out = & (Get-Module Pester) { + Invoke-InRunspacePool -InputObject @(1, 2, 3) -ThrottleLimit 3 -ScriptBlock { + param($item) + if (2 -eq $item) { throw 'worker blew up' } + $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,3' + ($errors.Count -gt 0) | Verify-True + ($errors -join ' ') | Verify-Like '*worker blew up*' + } + + t "returns nothing for an empty input" { + $r = & (Get-Module Pester) { + Invoke-InRunspacePool -InputObject @() -ThrottleLimit 2 -ScriptBlock { param($item) $item } + } + @($r).Count | Verify-Equal 0 + } + } + b "#pester:no-parallel directive parsing" { t "detects the directive when written as a comment" { $folder = New-ParallelTestFolder @@ -449,7 +511,7 @@ Describe 'S' { $r.TotalCount | Verify-Equal 1 $r.PassedCount | Verify-Equal 1 - ($warnings -join "`n") | Verify-Like (Get-ExpectedParallelFallbackWarning '*parallelizes only file-based runs*') + ($warnings -join "`n") | Verify-Like '*parallelizes only file-based runs*' } t "collects and merges code coverage across parallel workers" { @@ -596,7 +658,7 @@ Describe 'B' { It 'b1 never runs' { 1 | Should -Be 1 } } $r = Invoke-Pester -Configuration $c -WarningVariable warnings 3>$null - ($warnings -join "`n") | Verify-Like (Get-ExpectedParallelFallbackWarning "*does not support Run.SkipRemainingOnFailure*") + ($warnings -join "`n") | Verify-Like "*does not support Run.SkipRemainingOnFailure*" $r.TotalCount | Verify-Equal 3 $r.FailedCount | Verify-Equal 1 # a2 and the whole of B are skipped once the first test fails. @@ -678,29 +740,26 @@ Describe 'B' { It 'b1 passes' { 1 | Should -Be 1 } } # The run still executes in parallel and produces correct results. $r.PassedCount | Verify-Equal 2 - # PowerShell 5.1 has no ForEach-Object -Parallel and falls back to a sequential run whose - # output differs, so only assert the exact parallel rendering on 7+. - if ($PSVersionTable.PSVersion.Major -ge 7) { - # Rebuild the console text from the captured Write-Host records (honouring -NoNewline), - # then blank out the volatile version, temp paths and timings so the snapshot is stable. - $sb = [System.Text.StringBuilder]::new() - foreach ($rec in @($out)) { - if ($rec -isnot [System.Management.Automation.InformationRecord]) { continue } - $md = $rec.MessageData - if ($md -is [System.Management.Automation.HostInformationMessage]) { - $null = $sb.Append($md.Message) - if (-not $md.NoNewLine) { $null = $sb.Append("`n") } - } + # Rebuild the console text from the captured Write-Host records (honouring -NoNewline), + # then blank out the volatile version, temp paths and timings so the snapshot is stable. + $sb = [System.Text.StringBuilder]::new() + foreach ($rec in @($out)) { + if ($rec -isnot [System.Management.Automation.InformationRecord]) { continue } + $md = $rec.MessageData + if ($md -is [System.Management.Automation.HostInformationMessage]) { + $null = $sb.Append($md.Message) + if (-not $md.NoNewLine) { $null = $sb.Append("`n") } } - $normalized = $sb.ToString() ` - -replace 'Pester v\S+', 'Pester v' ` - -replace ([regex]::Escape($folder + [IO.Path]::DirectorySeparatorChar)), '' ` - -replace '\d+(.\d+)?m?s', '