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
81 changes: 73 additions & 8 deletions src/Main.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -626,9 +626,10 @@ 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+ and is not
# supported together with CodeCoverage yet; other cases fall back to the normal
# sequential path with a warning.
# 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).
$useParallel = $PesterPreference.Run.Parallel.Value
$parallelSupported = $PSVersionTable.PSVersion.Major -ge 7
$allFileContainers = 0 -eq @($containers | & $SafeCommands['Where-Object'] { 'File' -ne $_.Type }).Count
Expand All @@ -650,7 +651,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 $coverageEnabled -and -not $skipRemainingRunScope) {
if ($useParallel -and $parallelSupported -and $allFileContainers -and -not $skipRemainingRunScope) {
foreach ($fileContainer in $containers) {
if (Test-PesterFileIsNonParallel -Path $fileContainer.Item.FullName) {
$nonParallelContainers.Add($fileContainer)
Expand All @@ -667,9 +668,6 @@ function Invoke-Pester {
elseif ($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 $coverageEnabled) {
& $SafeCommands['Write-Warning'] "Run.Parallel does not support CodeCoverage yet. Running the tests sequentially instead."
}
elseif ($useParallel -and $skipRemainingRunScope) {
& $SafeCommands['Write-Warning'] "Run.Parallel does not support Run.SkipRemainingOnFailure = 'Run' because skipping after the first failure cannot span the isolated worker runspaces. Running the tests sequentially instead."
}
Expand All @@ -683,11 +681,28 @@ 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 $coverageEnabled -and -not $skipRemainingRunScope -and 0 -lt $parallelContainers.Count
$ranInParallel = $useParallel -and $parallelSupported -and $allFileContainers -and -not $skipRemainingRunScope -and 0 -lt $parallelContainers.Count
if ($ranInParallel) {
$foldedContainers = [System.Collections.Generic.List[object]]@()
$hasNonParallel = 0 -lt $nonParallelContainers.Count

# CodeCoverage in a parallel run: every worker measures the same locations with
# breakpoints and returns its per-location hits (the default profiler/tracer keeps its
# state in a process-global static and is not concurrency-safe). The parent collects
# those, adds the coverage of any #pester:no-parallel files it runs in-session, merges
# them, and lets the Coverage plugin's End step emit the single report and output file.
# Force breakpoint mode on the captured plugin configuration so the End step (and the
# in-session non-parallel measurement) does not try to use the tracer's Measure.
$collectCoverageInParallel = $coverageEnabled
$parallelCoverage = [System.Collections.Generic.List[object]]@()
$coveragePlugins = [System.Collections.Generic.List[object]]@()
if ($collectCoverageInParallel) {
$pluginConfiguration['Coverage'].UseBreakpoints = $true
foreach ($pl in $plugins) {
if ('Coverage' -eq $pl.Name) { $coveragePlugins.Add($pl) }
}
}

# The parent owns ALL framing for a parallel run. It fires the global and
# per-container/per-test plugin steps to a REPORTING-only plugin subset (screen
# output + IDE adapters) so the emitted events match a sequential run, while the
Expand Down Expand Up @@ -741,6 +756,11 @@ function Invoke-Pester {
# RSpec-folded - collect them straight away (do not re-fold).
foreach ($c in $parallelResult.Containers) { $foldedContainers.Add($c) }

# Gather this worker's measured coverage (already projected to a light shape).
if ($collectCoverageInParallel -and $null -ne $parallelResult.Coverage) {
foreach ($cc in $parallelResult.Coverage) { $parallelCoverage.Add($cc) }
}

# All-parallel: the last file just finished discovery, so global discovery
# is complete - fire DiscoveryEnd before replaying that file's run segment,
# exactly as the interleaved sequential path does.
Expand Down Expand Up @@ -785,8 +805,39 @@ function Invoke-Pester {
$runStartFired = $true
}

# Measure coverage for the in-session (#pester:no-parallel) files. Their
# Invoke-Test call runs with -SkipFrameworkGlobalSteps, which suppresses the
# Coverage plugin's own RunStart/RunEnd, so fire them here to set up and tear down
# breakpoints around this batch. UseBreakpoints was forced above, so this measures
# with breakpoints too and merges cleanly with the workers' hits.
if ($collectCoverageInParallel) {
Invoke-PluginStep -Plugins $coveragePlugins -Step RunStart -Context @{
Blocks = $foldedContainers
Configuration = $pluginConfiguration
Data = $pluginData
WriteDebugMessages = $PesterPreference.Debug.WriteDebugMessages.Value
Write_PesterDebugMessage = if ($PesterPreference.Debug.WriteDebugMessages.Value) { $script:SafeCommands['Write-PesterDebugMessage'] }
} -ThrowOnFailure
}

$r = Invoke-Test -BlockContainer $nonParallelContainers -Plugin $plugins -PluginConfiguration $pluginConfiguration -PluginData $pluginData -SessionState $sessionState -Filter $filter -Configuration $PesterPreference -BeforeContainerInit $beforeContainerInit -SkipFrameworkGlobalSteps

if ($collectCoverageInParallel) {
Invoke-PluginStep -Plugins $coveragePlugins -Step RunEnd -Context @{
Blocks = $foldedContainers
Configuration = $pluginConfiguration
Data = $pluginData
WriteDebugMessages = $PesterPreference.Debug.WriteDebugMessages.Value
Write_PesterDebugMessage = if ($PesterPreference.Debug.WriteDebugMessages.Value) { $script:SafeCommands['Write-PesterDebugMessage'] }
} -ThrowOnFailure

if ($pluginData.ContainsKey('Coverage') -and $null -ne $pluginData.Coverage) {
foreach ($cc in (Convert-CommandCoverageToProjection -CommandCoverage @($pluginData.Coverage.CommandCoverage))) {
$parallelCoverage.Add($cc)
}
}
}

$rspecResult = Split-RSpecResult -Result $r
if (0 -lt $rspecResult.StrayOutput.Count) {
$strayDescription = @(foreach ($strayItem in $rspecResult.StrayOutput) { "'$strayItem'" }) -join ', '
Expand Down Expand Up @@ -841,6 +892,20 @@ function Invoke-Pester {
if ($order.ContainsKey($key)) { $order[$key] } else { [int]::MaxValue }
}
})

# Merge every batch's measured locations into one CommandCoverage list and hand it to
# the plugin data, so the Coverage plugin's End step (fired once below) produces the
# single merged report and writes the output file. A location counts as covered when
# any file hit it; hit counts are summed across files.
if ($collectCoverageInParallel) {
$mergedCoverage = @(Merge-CoverageFromParallel -CommandCoverage $parallelCoverage.ToArray())
$pluginData['Coverage'] = @{
CommandCoverage = $mergedCoverage
Tracer = $null
Patched = $false
CoverageReport = $null
}
}
}
else {
$r = Invoke-Test -BlockContainer $containers -Plugin $plugins -PluginConfiguration $pluginConfiguration -PluginData $pluginData -SessionState $sessionState -Filter $filter -Configuration $PesterPreference -BeforeContainerInit $beforeContainerInit
Expand Down
18 changes: 15 additions & 3 deletions src/functions/Coverage.Plugin.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -131,20 +131,32 @@
$p.End = {
param($Context)

# Parallel workers collect the raw breakpoint hits but must not produce the report or write
# the output file themselves - the parent merges every worker's CommandCoverage and generates
# the report once. The flag is set on the (per-runspace) module scope only inside a worker, so
# a normal run and the parent's own End step never see it. See Invoke-TestInParallel.
if (defined CodeCoverageSkipReport) {
return
}

$run = $Context.TestRun

if ($PesterPreference.Output.Verbosity.Value -ne "None") {
$sw = [Diagnostics.Stopwatch]::StartNew()
Write-PesterHostMessage -ForegroundColor Magenta "Processing code coverage result."
}

$configuration = $run.PluginConfiguration.Coverage

$breakpoints = @($run.PluginData.Coverage.CommandCoverage)
$measure = if (-not $PesterPreference.CodeCoverage.UseBreakpoints.Value) { @($run.PluginData.Coverage.Tracer.Hits) }
# Read UseBreakpoints from the coverage plugin configuration captured at Start, not from the
# global $PesterPreference. A parallel run forces breakpoint-based coverage (the tracer uses a
# process-global static and is not concurrency-safe) by overriding it there, and the merged
# CommandCoverage already carries resolved HitCounts, so no tracer Measure is used.
$measure = if (-not $configuration.UseBreakpoints) { @($run.PluginData.Coverage.Tracer.Hits) }
$coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure
$totalMilliseconds = $run.Duration.TotalMilliseconds

$configuration = $run.PluginConfiguration.Coverage

$coverageXmlReport = switch ($configuration.OutputFormat) {
'JaCoCo' { [xml](Get-JaCoCoReportXml -CommandCoverage $breakpoints -TotalMilliseconds $totalMilliseconds -CoverageReport $coverageReport -ReportRoot (Get-ReportRoot)) }
'Cobertura' { [xml](Get-CoberturaReportXml -CoverageReport $coverageReport -TotalMilliseconds $totalMilliseconds -ReportRoot (Get-ReportRoot)) }
Expand Down
74 changes: 74 additions & 0 deletions src/functions/Coverage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,80 @@ function Merge-CommandCoverage {
$c
}

function Merge-CoverageFromParallel {
<#
.SYNOPSIS
Merges the per-worker breakpoint coverage of a parallel run into a single CommandCoverage list.

.DESCRIPTION
EXPERIMENTAL. In a parallel run each file measures the same set of measurable locations
(CodeCoverage.Path is identical for every worker), so every worker returns a full projection of
those locations with its own per-location HitCount (see Invoke-TestInParallel). A location is
covered when at least one file hit it, so this collapses the projections by
"File:StartLine:StartColumn" and sums the HitCounts, keeping the first occurrence's discovery
order. Each merged entry is shaped like a breakpoint-based CommandCoverage item (a Breakpoint
with a HitCount) so Get-CoverageReport / Get-JaCoCoReportXml / Get-CoberturaReportXml consume it
unchanged.
#>
[CmdletBinding()]
param ([object[]] $CommandCoverage)

$merged = [System.Collections.Specialized.OrderedDictionary]::new()
foreach ($cc in $CommandCoverage) {
if ($null -eq $cc) { continue }
$key = "$($cc.File):$($cc.StartLine):$($cc.StartColumn)"
if ($merged.Contains($key)) {
$merged[$key].Breakpoint.HitCount += [int] $cc.HitCount
}
else {
$merged[$key] = [PSCustomObject] @{
File = $cc.File
Class = $cc.Class
Function = $cc.Function
StartLine = $cc.StartLine
EndLine = $cc.EndLine
StartColumn = $cc.StartColumn
EndColumn = $cc.EndColumn
Command = $cc.Command
Breakpoint = @{ HitCount = [int] $cc.HitCount }
}
}
}

@($merged.Values)
}

function Convert-CommandCoverageToProjection {
<#
.SYNOPSIS
Projects raw CommandCoverage breakpoint objects into the lightweight, HitCount-carrying shape
that Merge-CoverageFromParallel expects.

.DESCRIPTION
EXPERIMENTAL. Drops the heavy Ast / live Breakpoint references and flattens the breakpoint hit
count onto a HitCount property, so a batch of coverage measured in-process (e.g. the sequential
#pester:no-parallel files of a parallel run) can be merged with the projections returned by
parallel workers.
#>
[CmdletBinding()]
param ([object[]] $CommandCoverage)

foreach ($cc in $CommandCoverage) {
if ($null -eq $cc) { continue }
[PSCustomObject] @{
File = $cc.File
Class = $cc.Class
Function = $cc.Function
StartLine = $cc.StartLine
EndLine = $cc.EndLine
StartColumn = $cc.StartColumn
EndColumn = $cc.EndColumn
Command = $cc.Command
HitCount = [int] $cc.Breakpoint.HitCount
}
}
}

function Get-CoverageReport {
# make sure this is an array, otherwise the counts start failing
# on powershell 3
Expand Down
Loading