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..46781236a 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. Setting it to 1 does not turn parallel off, the files still go through the parallel machinery one at a time, each in its own runspace, so a breakpoint set in the calling session still does not hit inside a worker.", 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..c7da58821 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. Setting it to 1 does not turn parallel off, the files still go through the parallel machinery one at a time, each in its own runspace, so a breakpoint set in the calling session still does not hit inside a worker. 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..c930fbd97 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,92 @@ 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. + + # Each worker counts itself in on entry and out on exit, so the run's peak concurrency is + # measured directly. Asserting on elapsed time instead would only infer concurrency, and it + # would sit on a threshold that a slow or a fast machine can cross for the wrong reason. + $probe = { + param($item, $state) + [System.Threading.Monitor]::Enter($state.SyncRoot) + try { + $state.Current++ + if ($state.Current -gt $state.Max) { $state.Max = $state.Current } + } + finally { [System.Threading.Monitor]::Exit($state.SyncRoot) } + + Start-Sleep -Milliseconds 300 + + [System.Threading.Monitor]::Enter($state.SyncRoot) + try { $state.Current-- } finally { [System.Threading.Monitor]::Exit($state.SyncRoot) } + + $item + } + + t "runs the items concurrently" { + $state = [hashtable]::Synchronized(@{ Current = 0; Max = 0 }) + $r = & (Get-Module Pester) { + param($state, $probe) + Invoke-InRunspacePool -InputObject @(1, 2, 3, 4) -ThrottleLimit 4 -Parameters @{ state = $state } -ScriptBlock $probe + } $state $probe + + (@($r) | Sort-Object) -join ',' | Verify-Equal '1,2,3,4' + # More than one in flight at once is the whole claim. The exact peak depends on how the + # pool schedules, so do not pin it to 4. + ($state.Max -gt 1) | Verify-True + } + + t "honours the throttle limit" { + # With one runspace the peak can only ever be 1, however the machine schedules them. + $state = [hashtable]::Synchronized(@{ Current = 0; Max = 0 }) + $r = & (Get-Module Pester) { + param($state, $probe) + Invoke-InRunspacePool -InputObject @(1, 2, 3, 4) -ThrottleLimit 1 -Parameters @{ state = $state } -ScriptBlock $probe + } $state $probe + + (@($r) | Sort-Object) -join ',' | Verify-Equal '1,2,3,4' + $state.Max | Verify-Equal 1 + } + + 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 +523,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 +670,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 +752,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', '