From a2038e87afc1211157544c5e07d53e356d3afe16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Thu, 13 May 2021 17:58:32 +0200 Subject: [PATCH] Add Profiler based code coverage (#1937) Do code coverage using the new Profiler approach under UseBreakpoints = $false experimental flag. --- src/Main.ps1 | 12 +- src/Pester.RSpec.ps1 | 3 + src/Pester.Runtime.ps1 | 2 +- src/Pester.psd1 | 6 +- .../Pester/CodeCoverageConfiguration.cs | 20 + .../Pester/Tracing/CodeCoveragePoint.cs | 46 ++ .../Pester/Tracing/CodeCoverageTracer.cs | 66 +++ .../Pester/Tracing/ExternalTracerAdapter.cs | 25 + src/csharp/Pester/Tracing/ITracer.cs | 10 + src/csharp/Pester/Tracing/Tracer.cs | 198 +++++++ src/csharp/Pester/Tracing/TracerHostUI.cs | 96 ++++ src/functions/Coverage.Plugin.ps1 | 25 +- src/functions/Coverage.ps1 | 175 ++++-- test.ps1 | 9 +- tst/functions/Coverage.Tests.ps1 | 527 +++++++++++------- 15 files changed, 946 insertions(+), 274 deletions(-) create mode 100644 src/csharp/Pester/Tracing/CodeCoveragePoint.cs create mode 100644 src/csharp/Pester/Tracing/CodeCoverageTracer.cs create mode 100644 src/csharp/Pester/Tracing/ExternalTracerAdapter.cs create mode 100644 src/csharp/Pester/Tracing/ITracer.cs create mode 100644 src/csharp/Pester/Tracing/Tracer.cs create mode 100644 src/csharp/Pester/Tracing/TracerHostUI.cs diff --git a/src/Main.ps1 b/src/Main.ps1 index 73c0f6b59..2b844d17e 100644 --- a/src/Main.ps1 +++ b/src/Main.ps1 @@ -969,6 +969,8 @@ function Invoke-Pester { Path = @($paths) RecursePaths = $PesterPreference.CodeCoverage.RecursePaths.Value TestExtension = $PesterPreference.Run.TestExtension.Value + UseSingleHitBreakpoints = $PesterPreference.CodeCoverage.SingleHitBreakpoints.Value + UseBreakpoints = $PesterPreference.CodeCoverage.UseBreakpoints.Value } $plugins += (Get-CoveragePlugin) @@ -1007,7 +1009,7 @@ function Invoke-Pester { Configuration = $pluginConfiguration GlobalPluginData = $state.PluginData WriteDebugMessages = $PesterPreference.Debug.WriteDebugMessages.Value - Write_PesterDebugMessage = if ($PesterPreference.Debug.WriteDebugMessages) { $script:SafeCommands['Write-PesterDebugMessage'] } + Write_PesterDebugMessage = if ($PesterPreference.Debug.WriteDebugMessages.Value) { $script:SafeCommands['Write-PesterDebugMessage'] } } -ThrowOnFailure } @@ -1016,16 +1018,13 @@ function Invoke-Pester { return } + $r = Invoke-Test -BlockContainer $containers -Plugin $plugins -PluginConfiguration $pluginConfiguration -PluginData $pluginData -SessionState $sessionState -Filter $filter -Configuration $PesterPreference foreach ($c in $r) { Fold-Container -Container $c -OnTest { param($t) Add-RSpecTestObjectProperties $t } } - $parameters = @{ - PSBoundParameters = $PSBoundParameters - } - $run = [Pester.Run]::Create() $run.Executed = $true $run.ExecutedAt = $start @@ -1068,7 +1067,8 @@ function Invoke-Pester { & $SafeCommands["Write-Host"] -ForegroundColor Magenta "Processing code coverage result." } $breakpoints = @($run.PluginData.Coverage.CommandCoverage) - $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + $measure = if (-not $PesterPreference.CodeCoverage.UseBreakpoints.Value) { @($run.PluginData.Coverage.Tracer.Hits) } + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure $totalMilliseconds = $run.Duration.TotalMilliseconds $configuration = $run.PluginConfiguration.Coverage diff --git a/src/Pester.RSpec.ps1 b/src/Pester.RSpec.ps1 index 17dff53be..c3f7fdd88 100644 --- a/src/Pester.RSpec.ps1 +++ b/src/Pester.RSpec.ps1 @@ -351,6 +351,9 @@ function New-PesterConfiguration { CoveragePercentTarget: Target percent of code coverage that you want to achieve, default 75%. Default value: 75 + UseBreakpoints: EXPERIMENTAL: When false, use Profiler based tracer to do CodeCoverage instead of using breakpoints. + Default value: $true + SingleHitBreakpoints: Remove breakpoint when it is hit. Default value: $true diff --git a/src/Pester.Runtime.ps1 b/src/Pester.Runtime.ps1 index b6a938512..a9b61d820 100644 --- a/src/Pester.Runtime.ps1 +++ b/src/Pester.Runtime.ps1 @@ -1028,7 +1028,7 @@ function Run-Test { Configuration = $state.PluginConfiguration Data = $state.PluginData WriteDebugMessages = $PesterPreference.Debug.WriteDebugMessages.Value - Write_PesterDebugMessage = if ($PesterPreference.Debug.WriteDebugMessages) { $script:SafeCommands['Write-PesterDebugMessage'] } + Write_PesterDebugMessage = if ($PesterPreference.Debug.WriteDebugMessages.Value) { $script:SafeCommands['Write-PesterDebugMessage'] } } -ThrowOnFailure } foreach ($rootBlock in $Block) { diff --git a/src/Pester.psd1 b/src/Pester.psd1 index 10e797d8e..db80ae0df 100644 --- a/src/Pester.psd1 +++ b/src/Pester.psd1 @@ -4,7 +4,7 @@ RootModule = 'Pester.psm1' # Version number of this module. - ModuleVersion = '5.2.1' + ModuleVersion = '5.3.0' # ID used to uniquely identify this module GUID = 'a699dea5-2c73-4616-a270-1f7abb777e71' @@ -115,11 +115,11 @@ ReleaseNotes = 'https://github.com/pester/Pester/releases/tag/5.2.1' # Prerelease string of this module - Prerelease = '' + Prerelease = 'alpha1' } # Minimum assembly version required - RequiredAssemblyVersion = '5.2.0' + RequiredAssemblyVersion = '5.3.0' } # HelpInfo URI of this module diff --git a/src/csharp/Pester/CodeCoverageConfiguration.cs b/src/csharp/Pester/CodeCoverageConfiguration.cs index bd5d9f02f..3a3a0c082 100644 --- a/src/csharp/Pester/CodeCoverageConfiguration.cs +++ b/src/csharp/Pester/CodeCoverageConfiguration.cs @@ -28,6 +28,7 @@ public class CodeCoverageConfiguration : ConfigurationSection private StringArrayOption _path; private BoolOption _excludeTests; private BoolOption _recursePaths; + private BoolOption _useBps; private BoolOption _singleHitBreakpoints; private DecimalOption _coveragePercentTarget; @@ -47,6 +48,7 @@ public CodeCoverageConfiguration() : base("CodeCoverage configuration.") Path = new StringArrayOption("Directories or files to be used for codecoverage, by default the Path(s) from general settings are used, unless overridden here.", new string[0]); ExcludeTests = new BoolOption("Exclude tests from code coverage. This uses the TestFilter from general configuration.", true); RecursePaths = new BoolOption("Will recurse through directories in the Path option.", true); + UseBreakpoints = new BoolOption("EXPERIMENTAL: When false, use Profiler based tracer to do CodeCoverage instead of using breakpoints.", true); CoveragePercentTarget = new DecimalOption("Target percent of code coverage that you want to achieve, default 75%.", 75m); SingleHitBreakpoints = new BoolOption("Remove breakpoint when it is hit.", true); } @@ -65,6 +67,7 @@ public CodeCoverageConfiguration(IDictionary configuration) : this() CoveragePercentTarget = configuration.GetValueOrNull("CoveragePercentTarget") ?? CoveragePercentTarget; SingleHitBreakpoints = configuration.GetValueOrNull("SingleHitBreakpoints") ?? SingleHitBreakpoints; + UseBreakpoints = configuration.GetValueOrNull("UseBreakpoints") ?? UseBreakpoints; } } @@ -197,6 +200,23 @@ public DecimalOption CoveragePercentTarget } } + + public BoolOption UseBreakpoints + { + get { return _useBps; } + set + { + if (_useBps == null) + { + _useBps = value; + } + else + { + _useBps = new BoolOption(_useBps, value.Value); + } + } + } + public BoolOption SingleHitBreakpoints { get { return _singleHitBreakpoints; } diff --git a/src/csharp/Pester/Tracing/CodeCoveragePoint.cs b/src/csharp/Pester/Tracing/CodeCoveragePoint.cs new file mode 100644 index 000000000..6f62ec8e0 --- /dev/null +++ b/src/csharp/Pester/Tracing/CodeCoveragePoint.cs @@ -0,0 +1,46 @@ +namespace Pester.Tracing +{ + public struct CodeCoveragePoint + { + public static CodeCoveragePoint Create(string path, int line, int column, int bpColumn, string astText) + { + return new CodeCoveragePoint(path, line, column, bpColumn, astText); + } + + public CodeCoveragePoint(string path, int line, int column, int bpColumn, string astText) + { + Path = path; + Line = line; + Column = column; + BpColumn = bpColumn; + AstText = astText; + + // those are not for users to set, + // we use them to make CC output easier to debug + // because this will show in list of hits what we think + // should or should not hit, for performance just bool + // would be enough + Text = default; + Hit = false; + } + + public int Line; + public int Column; + public int BpColumn; + public string Path; + public string AstText; + + // those are not for users to set, + // we use them to make CC output easier to debug + // because this will show in list of hits what we think + // should or should not hit, for performance just bool + // would be enough + public string Text; + public bool Hit; + + public override string ToString() + { + return $"{Hit}:'{AstText}':{Line}:{Column}:{Path}"; + } + } +} diff --git a/src/csharp/Pester/Tracing/CodeCoverageTracer.cs b/src/csharp/Pester/Tracing/CodeCoverageTracer.cs new file mode 100644 index 000000000..10bc4bb80 --- /dev/null +++ b/src/csharp/Pester/Tracing/CodeCoverageTracer.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Management.Automation; +using System.Management.Automation.Language; + +namespace Pester.Tracing +{ + public class CodeCoverageTracer : ITracer + { + public static CodeCoverageTracer Create(List points) + { + return new CodeCoverageTracer(points); + } + + public CodeCoverageTracer(List points) + { + foreach (var point in points) + { + var key = $"{point.Line}:{point.Column}"; + if (!Hits.ContainsKey(point.Path)) + { + var lineColumn = new Dictionary { [key] = point }; + Hits.Add(point.Path, lineColumn); + continue; + } + + var hits = Hits[point.Path]; + if (!hits.ContainsKey(key)) + { + hits.Add(key, point); + continue; + } + + // if the key is there do nothing, we already set it to false + } + } + + // list of what Pester figures out from the AST that we care about for CC + // keyed as path -> line:column -> CodeCoveragePoint + public Dictionary> Hits { get; } = new Dictionary>(); + + public void Trace(IScriptExtent extent, ScriptBlock _, int __) + { + // ignore unbound scriptblocks + if (extent?.File == null) + return; + + // Console.WriteLine($"{extent.File}:{extent.StartLineNumber}:{extent.StartColumnNumber}:{extent.Text}"); + if (!Hits.TryGetValue(extent.File, out var lineColumn)) + return; + + var key2 = $"{extent.StartLineNumber}:{extent.StartColumnNumber}"; + if (!lineColumn.ContainsKey(key2)) + return; + + + var point = lineColumn[key2]; + if (point.Hit == true) + return; + + point.Hit = true; + point.Text = extent.Text; + + lineColumn[key2] = point; + } + } +} diff --git a/src/csharp/Pester/Tracing/ExternalTracerAdapter.cs b/src/csharp/Pester/Tracing/ExternalTracerAdapter.cs new file mode 100644 index 000000000..57f1e4fbc --- /dev/null +++ b/src/csharp/Pester/Tracing/ExternalTracerAdapter.cs @@ -0,0 +1,25 @@ +using System; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Reflection; + +namespace Pester.Tracing +{ + class ExternalTracerAdapter : ITracer + { + private object _tracer; + private MethodInfo _traceMethod; + + public ExternalTracerAdapter(object tracer) + { + _tracer = tracer ?? new NullReferenceException(nameof(tracer)); + var traceMethod = tracer.GetType().GetMethod("Trace", new Type[] { typeof(IScriptExtent), typeof(ScriptBlock), typeof(int) }); + _traceMethod = traceMethod ?? throw new InvalidOperationException("The provided tracer does not have Trace method with this signature: Trace(IScriptExtent extent, ScriptBlock scriptBlock, int level)"); + } + + public void Trace(IScriptExtent extent, ScriptBlock scriptBlock, int level) + { + _traceMethod.Invoke(_tracer, new object[] { extent, scriptBlock, level }); + } + } +} diff --git a/src/csharp/Pester/Tracing/ITracer.cs b/src/csharp/Pester/Tracing/ITracer.cs new file mode 100644 index 000000000..e8255c906 --- /dev/null +++ b/src/csharp/Pester/Tracing/ITracer.cs @@ -0,0 +1,10 @@ +using System.Management.Automation; +using System.Management.Automation.Language; + +namespace Pester.Tracing +{ + public interface ITracer + { + void Trace(IScriptExtent extent, ScriptBlock scriptBlock, int level); + } +} diff --git a/src/csharp/Pester/Tracing/Tracer.cs b/src/csharp/Pester/Tracing/Tracer.cs new file mode 100644 index 000000000..36ea436e0 --- /dev/null +++ b/src/csharp/Pester/Tracing/Tracer.cs @@ -0,0 +1,198 @@ +using System; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Management.Automation.Language; +using System.Reflection; +using NonGeneric = System.Collections; + +namespace Pester.Tracing +{ + public static class Tracer + { + private static Func GetTraceLineInfo; + private static Action ResetUI; + private static ITracer _tracer; + private static ITracer _tracer2; + + public static bool IsEnabled { get; private set; } + + public static bool HasTracer2 => _tracer2 != null; + + public static void Register(object tracer) + { + if (!IsEnabled) + throw new InvalidOperationException($"Tracer is not active, if you want to activate it call {nameof(Patch)}."); + + if (HasTracer2) + throw new InvalidOperationException("Tracer2 is already present."); + + _tracer2 = new ExternalTracerAdapter(tracer) ?? throw new ArgumentNullException(nameof(tracer)); + TraceLine(justTracer2: true); + } + + public static void Unregister() + { + if (!IsEnabled) + throw new InvalidOperationException("Tracer is not active."); + + if (!HasTracer2) + throw new InvalidOperationException("Tracer2 is not present."); + + TraceLine(justTracer2: true); + TraceLine(justTracer2: true); + _tracer2 = null; + } + + public static void Patch(int version, EngineIntrinsics context, PSHostUserInterface ui, ITracer tracer) + { + if (IsEnabled) + throw new InvalidOperationException($"Tracer is already active, if you want to add another tracer call {nameof(Register)}."); + + _tracer = tracer ?? throw new ArgumentNullException(nameof(tracer)); + + var uiFieldName = version >= 6 ? "_externalUI" : "externalUI"; + // we get InternalHostUserInterface, grab external ui from that and replace it with ours + var externalUIField = ui.GetType().GetField(uiFieldName, BindingFlags.Instance | BindingFlags.NonPublic); + var externalUI = (PSHostUserInterface)externalUIField.GetValue(ui); + + // replace it with out patched up UI that writes to profiler on debug + externalUIField.SetValue(ui, new TracerHostUI(externalUI, () => TraceLine(false))); + + ResetUI = () => + { + externalUIField.SetValue(ui, externalUI); + }; + + // getting MethodInfo of context._context.Debugger.TraceLine + var bf = BindingFlags.NonPublic | BindingFlags.Instance; + var contextInternal = context.GetType().GetField("_context", bf).GetValue(context); + var debugger = contextInternal.GetType().GetProperty("Debugger", bf).GetValue(contextInternal); + var debuggerType = debugger.GetType(); + + var callStackField = debuggerType.GetField("_callStack", BindingFlags.Instance | BindingFlags.NonPublic); + var _callStack = callStackField.GetValue(debugger); + + var callStackType = _callStack.GetType(); + + var countBindingFlags = BindingFlags.Instance | BindingFlags.NonPublic; + if (version == 3) + { + // in PowerShell 3 callstack is List not a struct CallStackList + // Count is public property + countBindingFlags = BindingFlags.Instance | BindingFlags.Public; + } + var countProperty = callStackType.GetProperty("Count", countBindingFlags); + var getCount = countProperty.GetMethod; + var empty = new object[0]; + var stack = callStackField.GetValue(debugger); + var initialLevel = (int)getCount.Invoke(stack, empty); + + if (version == 3) + { + // we do the same operation as in the TraceLineAction below, but here + // we resolve the static things like types and properties, and then in the + // action we just use them to get the live data without the overhead of looking + // up properties all the time. This might be internally done in the reflection code + // did not measure the impact, and it is probably done for us in the reflection api itself + // in modern verisons of runtime + var callStack1 = callStackField.GetValue(debugger); + var callStackList1 = (NonGeneric.IList)callStack1; + var level1 = callStackList1.Count - initialLevel; + var last1 = callStackList1[callStackList1.Count - 1]; + var lastType = last1.GetType(); + var functionContextProperty = lastType.GetProperty("FunctionContext", BindingFlags.NonPublic | BindingFlags.Instance); + var functionContext1 = functionContextProperty.GetValue(last1); + var functionContextType = functionContext1.GetType(); + + var scriptBlockField = functionContextType.GetField("_scriptBlock", BindingFlags.Instance | BindingFlags.NonPublic); + var currentPositionProperty = functionContextType.GetProperty("CurrentPosition", BindingFlags.Instance | BindingFlags.NonPublic); + + var scriptBlock1 = (ScriptBlock)scriptBlockField.GetValue(functionContext1); + var extent1 = (IScriptExtent)currentPositionProperty.GetValue(functionContext1); + + GetTraceLineInfo = () => + { + var callStack = callStackField.GetValue(debugger); + var callStackList = (NonGeneric.IList)callStack; + var level = callStackList.Count - initialLevel; + var last = callStackList[callStackList.Count - 1]; + var functionContext = functionContextProperty.GetValue(last); + + var scriptBlock = (ScriptBlock)scriptBlockField.GetValue(functionContext); + var extent = (IScriptExtent)currentPositionProperty.GetValue(functionContext); + + return new TraceLineInfo(extent, scriptBlock, level); + }; + } + else + { + var lastFunctionContextMethod = callStackType.GetMethod("LastFunctionContext", BindingFlags.Instance | BindingFlags.NonPublic); + + object functionContext1 = lastFunctionContextMethod.Invoke(callStackField.GetValue(debugger), empty); + var functionContextType = functionContext1.GetType(); + var scriptBlockField = functionContextType.GetField("_scriptBlock", BindingFlags.Instance | BindingFlags.NonPublic); + var currentPositionProperty = functionContextType.GetProperty("CurrentPosition", BindingFlags.Instance | BindingFlags.NonPublic); + + var scriptBlock1 = (ScriptBlock)scriptBlockField.GetValue(functionContext1); + var extent1 = (IScriptExtent)currentPositionProperty.GetValue(functionContext1); + + GetTraceLineInfo = () => + { + var callStack = callStackField.GetValue(debugger); + var level = (int)getCount.Invoke(callStack, empty) - initialLevel; + object functionContext = lastFunctionContextMethod.Invoke(callStack, empty); + var scriptBlock = (ScriptBlock)scriptBlockField.GetValue(functionContext); + var extent = (IScriptExtent)currentPositionProperty.GetValue(functionContext); + + return new TraceLineInfo(extent, scriptBlock, level); + }; + } + + IsEnabled = true; + + // Add another event to the top apart from the scriptblock invocation + // in Trace-ScriptInternal, this makes it more consistently work on first + // run. Without this, the triggering line sometimes does not show up as 99.9% + TraceLine(); + } + + public static void Unpatch() + { + IsEnabled = false; + // Add Set-PSDebug -Trace 0 event and also another one for the internal disable + // this make first run more consistent for some reason + TraceLine(); + TraceLine(); + ResetUI(); + _tracer = null; + _tracer2 = null; + } + + // keeping this public so I can write easier repros when something goes wrong, + // in that case we just need to patch, trace and unpatch and if that works then + // maybe the UI host does not work + public static void TraceLine(bool justTracer2 = false) + { + var traceLineInfo = GetTraceLineInfo(); + if (!justTracer2) + { + _tracer.Trace(traceLineInfo.Extent, traceLineInfo.ScriptBlock, traceLineInfo.Level); + } + _tracer2?.Trace(traceLineInfo.Extent, traceLineInfo.ScriptBlock, traceLineInfo.Level); + } + + private struct TraceLineInfo + { + public IScriptExtent Extent; + public ScriptBlock ScriptBlock; + public int Level; + + public TraceLineInfo(IScriptExtent extent, ScriptBlock scriptBlock, int level) + { + Extent = extent; + ScriptBlock = scriptBlock; + Level = level; + } + } + } +} diff --git a/src/csharp/Pester/Tracing/TracerHostUI.cs b/src/csharp/Pester/Tracing/TracerHostUI.cs new file mode 100644 index 000000000..4470d5a90 --- /dev/null +++ b/src/csharp/Pester/Tracing/TracerHostUI.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Management.Automation; +using System.Management.Automation.Host; +using System.Security; + +namespace Pester.Tracing +{ + internal class TracerHostUI : PSHostUserInterface + { + private PSHostUserInterface _ui; + private Action _trace; + + public TracerHostUI(PSHostUserInterface ui, Action trace) + { + _ui = ui; + _trace = trace; + } + + public override PSHostRawUserInterface RawUI => _ui.RawUI; + + public override Dictionary Prompt(string caption, string message, Collection descriptions) + { + return _ui.Prompt(caption, message, descriptions); + } + + public override int PromptForChoice(string caption, string message, Collection choices, int defaultChoice) + { + return _ui.PromptForChoice(caption, message, choices, defaultChoice); + } + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) + { + return _ui.PromptForCredential(caption, message, userName, targetName); + } + + public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) + { + return _ui.PromptForCredential(caption, message, userName, targetName, allowedCredentialTypes, options); + } + + public override string ReadLine() + { + return _ui.ReadLine(); + } + + public override SecureString ReadLineAsSecureString() + { + return _ui.ReadLineAsSecureString(); + } + + public override void Write(string value) + { + _ui.Write(value); + } + + public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) + { + _ui.Write(foregroundColor, backgroundColor, value); + } + + public override void WriteDebugLine(string message) + { + if (_trace == null) + _ui.WriteDebugLine(message); + + _trace(); + } + + public override void WriteErrorLine(string value) + { + _ui.WriteErrorLine(value); + } + + public override void WriteLine(string value) + { + _ui.WriteLine(value); + } + + public override void WriteProgress(long sourceId, ProgressRecord record) + { + _ui.WriteProgress(sourceId, record); + } + + public override void WriteVerboseLine(string message) + { + _ui.WriteVerboseLine(message); + } + + public override void WriteWarningLine(string message) + { + _ui.WriteWarningLine(message); + } + } +} diff --git a/src/functions/Coverage.Plugin.ps1 b/src/functions/Coverage.Plugin.ps1 index 148a2fec8..bbe5b32e9 100644 --- a/src/functions/Coverage.Plugin.ps1 +++ b/src/functions/Coverage.Plugin.ps1 @@ -26,30 +26,37 @@ function Get-CoveragePlugin { & $logger "Config: $($config | & $script:SafeCommands['Out-String'])" } - $breakpoints = Enter-CoverageAnalysis -CodeCoverage $config -Logger $logger + $breakpoints = Enter-CoverageAnalysis -CodeCoverage $config -Logger $logger -UseBreakpoints $config.UseBreakpoints -UseSingleHitBreakpoints $config.UseSingleHitBreakpoints + + if (-not $config.UseBreakpoints) { + $tracer = Start-TraceScript $breakpoints + } $Context.Data.Add('Coverage', @{ CommandCoverage = $breakpoints + Tracer = $tracer CoverageReport = $null }) - $count = @($breakpoints).Count - if ($null -ne $logger) { - & $logger "Added $count breakpoints in $($sw.ElapsedMilliseconds) ms." - } if ($PesterPreference.Output.Verbosity.Value -in "Detailed", "Diagnostic") { - & $SafeCommands["Write-Host"] -ForegroundColor Magenta "Code Coverage set $count breakpoints in $($sw.ElapsedMilliseconds) ms." + & $SafeCommands["Write-Host"] -ForegroundColor Magenta "Code Coverage preparation finished after $($sw.ElapsedMilliseconds) ms." } } -End { param($Context) + $config = $Context.Configuration['Coverage'] + if (-not $config.UseBreakpoints) { + Stop-TraceScript + } if (-not $Context.TestRun.PluginData.ContainsKey("Coverage")) { return } $coverageData = $Context.TestRun.PluginData.Coverage - $breakpoints = $coverageData.CommandCoverage - - Exit-CoverageAnalysis -CommandCoverage $breakpoints + #TODO: rather check the config to see which mode of coverage we used + if ($null -eq $coverageData.Tracer) { + # we used breakpoints to measure CC, clean them up + Exit-CoverageAnalysis -CommandCoverage $coverageData.CommandCoverage + } } } diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index 5fc785085..399352fe5 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -2,7 +2,9 @@ function Enter-CoverageAnalysis { [CmdletBinding()] param ( [object[]] $CodeCoverage, - [ScriptBlock] $Logger + [ScriptBlock] $Logger, + [bool] $UseSingleHitBreakpoints = $true, + [bool] $UseBreakpoints = $true ) if ($null -ne $logger) { @@ -11,8 +13,8 @@ function Enter-CoverageAnalysis { $sw = [System.Diagnostics.Stopwatch]::StartNew() $coverageInfo = foreach ($object in $CodeCoverage) { - Get-CoverageInfoFromUserInput -InputObject $object -Logger $Logger - } + Get-CoverageInfoFromUserInput -InputObject $object -Logger $Logger + } if ($null -eq $coverageInfo) { if ($null -ne $logger) { @@ -22,39 +24,48 @@ function Enter-CoverageAnalysis { return @() } - + # breakpoints collection actually contains locations in script that are interesting, + # not actual breakpoints $breakpoints = @(Get-CoverageBreakpoints -CoverageInfo $coverageInfo -Logger $Logger) if ($null -ne $logger) { - & $logger "Figuring out $($breakpoints.Count) breakpoints took $($sw.ElapsedMilliseconds) ms." + & $logger "Figuring out $($breakpoints.Count) measurable code locations took $($sw.ElapsedMilliseconds) ms." } - $action = if ($PesterPreference.CodeCoverage.SingleHitBreakpoints.Value) { + + if ($UseBreakpoints) { if ($null -ne $logger) { - & $logger "Using single hit breakpoints." + & $logger "Using breakpoints for code coverage. Setting $($breakpoints.Count) breakpoints." } - # remove itself on hit - { & $SafeCommands['Remove-PSBreakpoint'] -Id $_.Id } - } - else { - if ($null -ne $logger) { - & $logger "Using normal breakpoints." + $action = if ($UseSingleHitBreakpoints) { + # remove itself on hit + { & $SafeCommands['Remove-PSBreakpoint'] -Id $_.Id } } + else { + if ($null -ne $logger) { + & $logger "Using normal breakpoints." + } - # empty ScriptBlock - {} - } + # empty ScriptBlock + {} + } - foreach ($breakpoint in $breakpoints) { - $params = $breakpoint.Breakpointlocation - $params.Action = $action + foreach ($breakpoint in $breakpoints) { + $params = $breakpoint.Breakpointlocation + $params.Action = $action - $breakpoint.Breakpoint = & $SafeCommands['Set-PSBreakpoint'] @params - } + $breakpoint.Breakpoint = & $SafeCommands['Set-PSBreakpoint'] @params + } - $sw.Stop() + $sw.Stop() - if ($null -ne $logger) { - & $logger "Setting $($breakpoints.Count) breakpoints took $($sw.ElapsedMilliseconds) ms." + if ($null -ne $logger) { + & $logger "Setting $($breakpoints.Count) breakpoints took $($sw.ElapsedMilliseconds) ms." + } + } + else { + if ($null -ne $logger) { + & $logger "Using Profiler based tracer for code coverage, not setting any breakpoints." + } } return $breakpoints @@ -213,7 +224,8 @@ function Get-CodeCoverageFilePaths { } elseif (-not $i.PSIsContainer) { $i.PSPath - }} + } + } Get-CodeCoverageFilePaths -Paths $children -IncludeTests $IncludeTests -RecursePaths $RecursePaths } elseif (-not $item.PsIsContainer) { @@ -353,7 +365,7 @@ function New-CoverageBreakpoint { return } - $params = @{ + $params = @{ Script = $Command.Extent.File Line = $Command.Extent.StartLineNumber Column = $Command.Extent.StartColumnNumber @@ -363,17 +375,17 @@ function New-CoverageBreakpoint { } [pscustomobject] @{ - File = $Command.Extent.File - Class = Get-ParentClassName -Ast $Command - Function = Get-ParentFunctionName -Ast $Command - StartLine = $Command.Extent.StartLineNumber - EndLine = $Command.Extent.EndLineNumber - StartColumn = $Command.Extent.StartColumnNumber - EndColumn = $Command.Extent.EndColumnNumber - Command = Get-CoverageCommandText -Ast $Command + File = $Command.Extent.File + Class = Get-ParentClassName -Ast $Command + Function = Get-ParentFunctionName -Ast $Command + StartLine = $Command.Extent.StartLineNumber + EndLine = $Command.Extent.EndLineNumber + StartColumn = $Command.Extent.StartColumnNumber + EndColumn = $Command.Extent.EndColumnNumber + Command = Get-CoverageCommandText -Ast $Command # keep property for breakpoint but we will set it later - Breakpoint = $null - BreakpointLocation = $params + Breakpoint = $null + BreakpointLocation = $params } } @@ -638,7 +650,40 @@ function Merge-CommandCoverage { function Get-CoverageReport { # make sure this is an array, otherwise the counts start failing # on powershell 3 - param ([object[]] $CommandCoverage) + param ([object[]] $CommandCoverage, $Measure) + + # Measure is null when we used Breakpoints to do code coverage, otherwise it is populated with the measure + if ($null -ne $Measure) { + + # re-key the measures to use columns that are corrected for BP placement + $bpm = @{} + foreach ($path in $Measure.Keys) { + $lines = @{} + + foreach ($line in $Measure[$path].Values) { + $lines.Add("$($line.Line):$($line.BpColumn)", $line) + } + + $bpm.Add($path, $lines) + } + + # adapting the data to the breakpoint like api we use for breakpoint based CC + # so the rest of our code just works + foreach ($i in $CommandCoverage) { + # Write-Host "CC: $($i.File), $($i.StartLine), $($i.StartColumn)" + $bp = @{ HitCount = 0 } + if ($bpm.ContainsKey($i.File)) { + $f = $bpm[$i.File] + $key = "$($i.StartLine):$($i.StartColumn)" + if ($f.ContainsKey($key)) { + $h = $f[$key] + $bp.HitCount = [int] $h.Hit + } + } + + $i.Breakpoint = $bp + } + } $properties = @( 'File' @@ -675,13 +720,14 @@ function Get-CommonParentPath { if ("CoverageGutters" -eq $PesterPreference.CodeCoverage.OutputFormat.Value) { # for coverage gutters the root path is relative to the coverage.xml - return (& $SafeCommands['Split-Path'] -Path $PesterPreference.CodeCoverage.OutputPath.Value | Normalize-Path ) + $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($PesterPreference.CodeCoverage.OutputPath.Value) + return (& $SafeCommands['Split-Path'] -Path $fullPath | Normalize-Path ) } $pathsToTest = @( $Path | - Normalize-Path | - & $SafeCommands['Select-Object'] -Unique + Normalize-Path | + & $SafeCommands['Select-Object'] -Unique ) if ($pathsToTest.Count -gt 0) { @@ -896,11 +942,11 @@ function Get-JaCoCoReportXml { $class = $package.Classes.$file $classElementRelativePath = (Get-RelativePath -Path $file -RelativeTo $commonParent).Replace("\", "/") $classElementName = if ($isGutters) { - $classElementRelativePath - } - else { - "$commonParentLeaf/$classElementRelativePath" - } + $classElementRelativePath + } + else { + "$commonParentLeaf/$classElementRelativePath" + } $classElementName = $classElementName.Substring(0, $($classElementName.LastIndexOf("."))) $classElement = Add-XmlElement $packageElement 'class' -Attributes ([ordered] @{ name = $classElementName @@ -1007,3 +1053,42 @@ function Add-JaCoCoCounter { covered = $Data.$Type.Covered }) } + +function Start-TraceScript ($Breakpoints) { + + $points = [Collections.Generic.List[Pester.Tracing.CodeCoveragePoint]]@() + foreach ($breakpoint in $breakpoints) { + $location = $breakpoint.BreakpointLocation + + $hitColumn = $location.Column + + # breakpoints for some actions bind to different column than the hits, we need to adjust + # when code contains assignment we need to translate it, because we are reporting the place where BP would bind as interesting + # but we are getting the whole assignment from profiler, so we need to offset it + $firstLine, $null = $breakpoint.Command -split "`n",2 + if ($firstLine -like "*=*") { + $ast = [System.Management.Automation.Language.Parser]::ParseInput($breakpoint.Command, [ref]$null, [ref]$null) + + $assignment = $ast.Find( { param ($item) $item -is [System.Management.Automation.Language.AssignmentStatementAst] }, $false) + if ($assignment) { + if ($assignment.Right) { + $hitColumn = $location.Column - $assignment.Right.Extent.StartColumnNumber + 1 + } + } + } + + + $points.Add([Pester.Tracing.CodeCoveragePoint]::Create($location.Script, $location.Line, $hitColumn, $location.Column, $breakpoint.Command)); + } + + $tracer = [Pester.Tracing.CodeCoverageTracer]::Create($points) + [Pester.Tracing.Tracer]::Patch($PSVersionTable.PSVersion.Major, $ExecutionContext, $host.UI, $tracer) + Set-PSDebug -Trace 1 + + $tracer +} + +function Stop-TraceScript { + Set-PSDebug -Trace 0 + [Pester.Tracing.Tracer]::Unpatch() +} diff --git a/test.ps1 b/test.ps1 index d0d9a4ac4..fc3cb6080 100644 --- a/test.ps1 +++ b/test.ps1 @@ -121,10 +121,10 @@ New-Module -Name TestHelpers -ScriptBlock { $configuration = [PesterConfiguration]::Default -$configuration.Debug.WriteDebugMessages = $false -# $configuration.Debug.WriteDebugMessagesFrom = 'CodeCoverage' +$configuration.Output.Verbosity = "Normal" +$configuration.Debug.WriteDebugMessages = $true +$configuration.Debug.WriteDebugMessagesFrom = 'CodeCoverage' -# $configuration.Output.Verbosity = "Detailed" $configuration.Debug.ShowFullErrors = $false $configuration.Debug.ShowNavigationMarkers = $false @@ -146,8 +146,9 @@ if ($CI) { # not using code coverage, it is still very slow $configuration.CodeCoverage.Enabled = $false $configuration.CodeCoverage.Path = "$PSScriptRoot/src/*" - $configuration.CodeCoverage.SingleHitBreakpoints = $true + # experimental, uses the Profiler based tracer to do code coverage without using breakpoints + $configuration.CodeCoverage.UseBreakpoints = $false $configuration.TestResult.Enabled = $true } diff --git a/tst/functions/Coverage.Tests.ps1 b/tst/functions/Coverage.Tests.ps1 index 114293b74..25920dfa1 100644 --- a/tst/functions/Coverage.Tests.ps1 +++ b/tst/functions/Coverage.Tests.ps1 @@ -112,9 +112,9 @@ InPesterModuleScope { '@ - $null = New-Item -Path $testScript3Path -ItemType File -ErrorAction SilentlyContinue + $null = New-Item -Path $testScript3Path -ItemType File -ErrorAction SilentlyContinue - Set-Content -Path $testScript3Path -Value @' + Set-Content -Path $testScript3Path -Value @' 'Some {0} file' ` -f ` 'other' @@ -122,17 +122,34 @@ InPesterModuleScope { '@ } - Context 'Entire file' { + Context 'Entire file measured using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler based cc" } + ) { BeforeAll { + # TODO: renaming, breakpoints mean "code point of interests" in most cases here, not actual breakpoints # Path deliberately duplicated to make sure the code doesn't produce multiple breakpoints for the same commands - $breakpoints = Enter-CoverageAnalysis -CodeCoverage $testScriptPath, $testScriptPath, $testScript2Path, $testScript3Path + $breakpoints = Enter-CoverageAnalysis -CodeCoverage $testScriptPath, $testScriptPath, $testScript2Path, $testScript3Path -UseBreakpoints $UseBreakpoints @($breakpoints).Count | Should -Be 18 -Because 'it has the proper number of breakpoints defined' - $null = & $testScriptPath - $null = & $testScript2Path - $null = & $testScript3Path - $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + $sb = { + $null = & $testScriptPath + $null = & $testScript2Path + $null = & $testScript3Path + } + + if ($UseBreakpoints) { + # with breakpoints + & $sb + } + else { + $tracer = Start-TraceScript $breakpoints + try { & $sb } finally { Stop-TraceScript } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure } It 'Reports the proper number of executed commands' { @@ -460,17 +477,30 @@ InPesterModuleScope { } AfterAll { - Exit-CoverageAnalysis -CommandCoverage $breakpoints + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } } } - Context 'Single function with missed commands' { + Context 'Single function with missed commands using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler based cc" } + ) { BeforeAll { - $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Function = 'FunctionTwo'} + $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Function = 'FunctionTwo' } @($breakpoints).Count | Should -Be 1 -Because "it has the proper number of breakpoints defined" - $null = & $testScriptPath - $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + if ($UseBreakpoints) { + & $testScriptPath + } + else { + $tracer = Start-TraceScript $breakpoints + try { & $testScriptPath } finally { Stop-TraceScript } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure } It 'Reports the proper number of executed commands' { @@ -494,19 +524,32 @@ InPesterModuleScope { } AfterAll { - Exit-CoverageAnalysis -CommandCoverage $breakpoints + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } } } - Context 'Single function with no missed commands' { + Context 'Single function with no missed commands using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler based cc" } + ) { BeforeAll { - $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Function = 'FunctionOne'} + $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Function = 'FunctionOne' } @($breakpoints).Count | Should -Be 9 -Because "it has the proper number of breakpoints defined" - $null = & $testScriptPath - $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + if ($UseBreakpoints) { + & $testScriptPath + } + else { + $tracer = Start-TraceScript $breakpoints + try { & $testScriptPath } finally { Stop-TraceScript } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure } It 'Reports the proper number of executed commands' { @@ -530,19 +573,32 @@ InPesterModuleScope { } AfterAll { - Exit-CoverageAnalysis -CommandCoverage $breakpoints + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } } } - Context 'Range of lines' { + Context 'Range of lines using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler based cc" } + ) { BeforeAll { $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; StartLine = 11; EndLine = 12 } @($breakpoints).Count | Should -Be 2 -Because 'it has the proper number of breakpoints defined' - $null = & $testScriptPath - $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + if ($UseBreakpoints) { + & $testScriptPath + } + else { + $tracer = Start-TraceScript $breakpoints + try { & $testScriptPath } finally { Stop-TraceScript } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure } It 'Reports the proper number of executed commands' { @@ -566,18 +622,31 @@ InPesterModuleScope { } AfterAll { - Exit-CoverageAnalysis -CommandCoverage $breakpoints + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } } } - Context 'Function wildcard resolution' { + Context 'Function wildcard resolution using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler based cc" } + ) { BeforeAll { $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = "$(Join-Path -Path $root -ChildPath *.ps1)"; Function = '*' } @($breakpoints).Count | Should -Be 13 -Because 'it has the proper number of breakpoints defined' - $null = & $testScriptPath - $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + if ($UseBreakpoints) { + & $testScriptPath + } + else { + $tracer = Start-TraceScript $breakpoints + try { & $testScriptPath } finally { Stop-TraceScript } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure } It 'Reports the proper number of executed commands' { @@ -610,21 +679,34 @@ InPesterModuleScope { } AfterAll { - Exit-CoverageAnalysis -CommandCoverage $breakpoints + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } } } # Classes have been introduced in PowerShell 5.0 if ($PSVersionTable.PSVersion.Major -ge 5) { - Context 'Single class' { + Context 'Single class using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler based cc" } + ) { BeforeAll { - $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Class = 'MyClass'} + $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Class = 'MyClass' } @($breakpoints).Count | Should -Be 3 -Because 'it has the proper number of breakpoints defined' - $null = & $testScriptPath - $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + if ($UseBreakpoints) { + & $testScriptPath + } + else { + $tracer = Start-TraceScript $breakpoints + try { & $testScriptPath } finally { Stop-TraceScript } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure } It 'Reports the proper number of executed commands' { @@ -644,19 +726,32 @@ InPesterModuleScope { } AfterAll { - Exit-CoverageAnalysis -CommandCoverage $breakpoints + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } } } - Context 'Class wildcard resolution' { + Context 'Class wildcard resolution using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler based cc" } + ) { BeforeAll { - $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Class = '*'} + $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Class = '*' } @($breakpoints).Count | Should -Be 3 -Because 'it has the proper number of breakpoints defined' - $null = & $testScriptPath - $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + if ($UseBreakpoints) { + & $testScriptPath + } + else { + $tracer = Start-TraceScript $breakpoints + try { & $testScriptPath } finally { Stop-TraceScript } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure } It 'Reports the proper number of executed commands' { @@ -676,14 +771,19 @@ InPesterModuleScope { } AfterAll { - Exit-CoverageAnalysis -CommandCoverage $breakpoints + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } } } - Context 'Class and function filter' { + Context 'Class and function filter using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler based cc" } + ) { BeforeAll { - $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Class = 'MyClass'; Function = 'MethodTwo'} + $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Class = 'MyClass'; Function = 'MethodTwo' } @($breakpoints).Count | Should -Be 1 -Because 'it has the proper number of breakpoints defined' @@ -708,20 +808,33 @@ InPesterModuleScope { } AfterAll { - Exit-CoverageAnalysis -CommandCoverage $breakpoints + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } } } } else { - Context 'Single class when not supported' { + Context 'Single class when not supported using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler based cc" } + ) { BeforeAll { - $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{Path = $testScriptPath; Class = 'MyClass'} + $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{ Path = $testScriptPath; Class = 'MyClass' } @($breakpoints).Count | Should -Be 0 -Because 'it has the proper number of breakpoints defined' - $null = & $testScriptPath - $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + if ($UseBreakpoints) { + & $testScriptPath + } + else { + $tracer = Start-TraceScript $breakpoints + try { & $testScriptPath } finally { Stop-TraceScript } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure } It 'Reports the proper number of executed commands' { @@ -741,219 +854,221 @@ InPesterModuleScope { } AfterAll { - Exit-CoverageAnalysis -CommandCoverage $breakpoints + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } } } } } -Describe 'Path resolution for test files' { - BeforeAll { - $root = (Get-PSDrive TestDrive).Root - $rootSubFolder = Join-Path -Path $root -ChildPath TestSubFolder - - $null = New-Item -Path $rootSubFolder -ItemType Directory -ErrorAction SilentlyContinue - $null = New-Item -Path $(Join-Path -Path $root -ChildPath TestScript.psm1) -ItemType File -ErrorAction SilentlyContinue - $null = New-Item -Path $(Join-Path -Path $root -ChildPath TestScript.ps1) -ItemType File -ErrorAction SilentlyContinue - $null = New-Item -Path $(Join-Path -Path $root -ChildPath TestScript.tests.ps1) -ItemType File -ErrorAction SilentlyContinue - $null = New-Item -Path $(Join-Path -Path $root -ChildPath TestScript2.tests.ps1) -ItemType File -ErrorAction SilentlyContinue - $null = New-Item -Path $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.ps1) -Force -ItemType File -ErrorAction SilentlyContinue - $null = New-Item -Path $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.tests.ps1) -Force -ItemType File -ErrorAction SilentlyContinue - } - Context 'Using Path-input (auto-detect)' { - It 'Includes script files by default when using wildcard path' { - $coverageInfo = Get-CoverageInfoFromUserInput "$(Join-Path -Path $root -ChildPath *)" - $PesterTests = @($coverageInfo | + Describe 'Path resolution for test files' { + BeforeAll { + $root = (Get-PSDrive TestDrive).Root + $rootSubFolder = Join-Path -Path $root -ChildPath TestSubFolder + + $null = New-Item -Path $rootSubFolder -ItemType Directory -ErrorAction SilentlyContinue + $null = New-Item -Path $(Join-Path -Path $root -ChildPath TestScript.psm1) -ItemType File -ErrorAction SilentlyContinue + $null = New-Item -Path $(Join-Path -Path $root -ChildPath TestScript.ps1) -ItemType File -ErrorAction SilentlyContinue + $null = New-Item -Path $(Join-Path -Path $root -ChildPath TestScript.tests.ps1) -ItemType File -ErrorAction SilentlyContinue + $null = New-Item -Path $(Join-Path -Path $root -ChildPath TestScript2.tests.ps1) -ItemType File -ErrorAction SilentlyContinue + $null = New-Item -Path $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.ps1) -Force -ItemType File -ErrorAction SilentlyContinue + $null = New-Item -Path $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.tests.ps1) -Force -ItemType File -ErrorAction SilentlyContinue + } + Context 'Using Path-input (auto-detect)' { + It 'Includes script files by default when using wildcard path' { + $coverageInfo = Get-CoverageInfoFromUserInput "$(Join-Path -Path $root -ChildPath *)" + $PesterTests = @($coverageInfo | Select-Object -ExpandProperty Path | Where-Object { $_ -notmatch '\.tests.ps1$' }) - $PesterTests.Count | Should -Be 3 - $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.psm1) - $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.ps1) - $PesterTests | Should -Contain $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.ps1) - } - It 'Excludes test files by default when using wildcard path' { - $coverageInfo = Get-CoverageInfoFromUserInput "$(Join-Path -Path $root -ChildPath *)" - $PesterTests = @($coverageInfo | + $PesterTests.Count | Should -Be 3 + $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.psm1) + $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.ps1) + $PesterTests | Should -Contain $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.ps1) + } + It 'Excludes test files by default when using wildcard path' { + $coverageInfo = Get-CoverageInfoFromUserInput "$(Join-Path -Path $root -ChildPath *)" + $PesterTests = @($coverageInfo | Select-Object -ExpandProperty Path | Where-Object { $_ -match '\.tests.ps1$' }) - $PesterTests | Should -BeNullOrEmpty - } - It 'Includes test files when specified in wildcard path' { - $coverageInfo = Get-CoverageInfoFromUserInput "$(Join-Path -Path $root -ChildPath *.tests.ps1)" - $PesterTests = @($coverageInfo | + $PesterTests | Should -BeNullOrEmpty + } + It 'Includes test files when specified in wildcard path' { + $coverageInfo = Get-CoverageInfoFromUserInput "$(Join-Path -Path $root -ChildPath *.tests.ps1)" + $PesterTests = @($coverageInfo | Select-Object -ExpandProperty Path | Where-Object { $_ -match '\.tests.ps1$' }) - $PesterTests.Count | Should -Be 2 - $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.tests.ps1) - $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript2.tests.ps1) - } - It 'Includes test file when targeted directly using filepath' { - $path = Join-Path -Path $root -ChildPath TestScript.tests.ps1 - $coverageInfo = Get-CoverageInfoFromUserInput $path - $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path - $PesterTests | Should -Be $path - } - } - Context 'Using object-input' { - It 'Excludes test files when IncludeTests is not specified' { - $coverageInfo = Get-CoverageInfoFromUserInput @{ Path = "$(Join-Path -Path $root -ChildPath TestScript.tests.ps1)" } - $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path - $PesterTests | Should -BeNullOrEmpty - } - It 'Excludes test files when IncludeTests is false' { - $coverageInfo = Get-CoverageInfoFromUserInput @{ Path = "$(Join-Path -Path $root -ChildPath TestScript.tests.ps1)"; IncludeTests = $false } - $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path - $PesterTests | Should -BeNullOrEmpty - } - It 'Includes test files when IncludeTests is true' { - $path = Join-Path -Path $root -ChildPath TestScript.tests.ps1 - $coverageInfo = Get-CoverageInfoFromUserInput @{ Path = $path; IncludeTests = $true } - $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path - $PesterTests | Should -Be $path + $PesterTests.Count | Should -Be 2 + $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.tests.ps1) + $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript2.tests.ps1) + } + It 'Includes test file when targeted directly using filepath' { + $path = Join-Path -Path $root -ChildPath TestScript.tests.ps1 + $coverageInfo = Get-CoverageInfoFromUserInput $path + $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path + $PesterTests | Should -Be $path + } } - It 'Includes test files when IncludeTests is true and using wildcard path' { - $coverageInfo = Get-CoverageInfoFromUserInput @{ Path = "$(Join-Path -Path $root -ChildPath *)"; IncludeTests = $true } - $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path - $PesterTests.Count | Should -Be 6 - $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.psm1) - $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.ps1) - $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.tests.ps1) - $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript2.tests.ps1) - $PesterTests | Should -Contain $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.ps1) - $PesterTests | Should -Contain $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.tests.ps1) + Context 'Using object-input' { + It 'Excludes test files when IncludeTests is not specified' { + $coverageInfo = Get-CoverageInfoFromUserInput @{ Path = "$(Join-Path -Path $root -ChildPath TestScript.tests.ps1)" } + $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path + $PesterTests | Should -BeNullOrEmpty + } + It 'Excludes test files when IncludeTests is false' { + $coverageInfo = Get-CoverageInfoFromUserInput @{ Path = "$(Join-Path -Path $root -ChildPath TestScript.tests.ps1)"; IncludeTests = $false } + $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path + $PesterTests | Should -BeNullOrEmpty + } + It 'Includes test files when IncludeTests is true' { + $path = Join-Path -Path $root -ChildPath TestScript.tests.ps1 + $coverageInfo = Get-CoverageInfoFromUserInput @{ Path = $path; IncludeTests = $true } + $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path + $PesterTests | Should -Be $path + } + It 'Includes test files when IncludeTests is true and using wildcard path' { + $coverageInfo = Get-CoverageInfoFromUserInput @{ Path = "$(Join-Path -Path $root -ChildPath *)"; IncludeTests = $true } + $PesterTests = $coverageInfo | Select-Object -ExpandProperty Path + $PesterTests.Count | Should -Be 6 + $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.psm1) + $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.ps1) + $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript.tests.ps1) + $PesterTests | Should -Contain $(Join-Path -Path $root -ChildPath TestScript2.tests.ps1) + $PesterTests | Should -Contain $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.ps1) + $PesterTests | Should -Contain $(Join-Path -Path $rootSubFolder -ChildPath TestScript3.tests.ps1) + } } } -} -# Describe 'Stripping common parent paths' { + # Describe 'Stripping common parent paths' { -# If ( (& $SafeCommands['Get-Variable'] -Name IsLinux -Scope Global -ErrorAction SilentlyContinue) -or -# (& $SafeCommands['Get-Variable'] -Name IsMacOS -Scope Global -ErrorAction SilentlyContinue)) { + # If ( (& $SafeCommands['Get-Variable'] -Name IsLinux -Scope Global -ErrorAction SilentlyContinue) -or + # (& $SafeCommands['Get-Variable'] -Name IsMacOS -Scope Global -ErrorAction SilentlyContinue)) { -# $paths = @( -# Normalize-Path '/usr/lib/Common\Folder\UniqueSubfolder1/File.ps1' -# Normalize-Path '/usr/lib/Common\Folder\UniqueSubfolder2/File2.ps1' -# Normalize-Path '/usr/lib/Common\Folder\UniqueSubfolder3/File3.ps1' + # $paths = @( + # Normalize-Path '/usr/lib/Common\Folder\UniqueSubfolder1/File.ps1' + # Normalize-Path '/usr/lib/Common\Folder\UniqueSubfolder2/File2.ps1' + # Normalize-Path '/usr/lib/Common\Folder\UniqueSubfolder3/File3.ps1' -# $expectedCommonPath = Normalize-Path '/usr/lib/Common/Folder' + # $expectedCommonPath = Normalize-Path '/usr/lib/Common/Folder' -# ) + # ) -# } -# Else { + # } + # Else { -# $paths = @( -# Normalize-Path 'C:\Common\Folder\UniqueSubfolder1/File.ps1' -# Normalize-Path 'C:\Common\Folder\UniqueSubfolder2/File2.ps1' -# Normalize-Path 'C:\Common\Folder\UniqueSubfolder3/File3.ps1' + # $paths = @( + # Normalize-Path 'C:\Common\Folder\UniqueSubfolder1/File.ps1' + # Normalize-Path 'C:\Common\Folder\UniqueSubfolder2/File2.ps1' + # Normalize-Path 'C:\Common\Folder\UniqueSubfolder3/File3.ps1' -# $expectedCommonPath = Normalize-Path 'C:\Common/Folder' + # $expectedCommonPath = Normalize-Path 'C:\Common/Folder' -# ) + # ) -# } + # } -# $commonPath = Get-CommonParentPath -Path $paths + # $commonPath = Get-CommonParentPath -Path $paths -# It 'Identifies the correct parent path' { -# $commonPath | Should -Be $expectedCommonPath -# } + # It 'Identifies the correct parent path' { + # $commonPath | Should -Be $expectedCommonPath + # } -# $expectedRelativePath = Normalize-Path 'UniqueSubfolder1/File.ps1' -# $relativePath = Get-RelativePath -Path $paths[0] -RelativeTo $commonPath + # $expectedRelativePath = Normalize-Path 'UniqueSubfolder1/File.ps1' + # $relativePath = Get-RelativePath -Path $paths[0] -RelativeTo $commonPath -# It 'Strips the common path correctly' { -# $relativePath | Should -Be $expectedRelativePath -# } -# } + # It 'Strips the common path correctly' { + # $relativePath | Should -Be $expectedRelativePath + # } + # } -# #Workaround for Linux and MacOS - they don't have DSC by default installed with PowerShell - disable tests on these platforms -# if ((Get-Module -ListAvailable PSDesiredStateConfiguration) -and $PSVersionTable.PSVersion.Major -ge 4 -and ((GetPesterOS) -eq 'Windows')) { + # #Workaround for Linux and MacOS - they don't have DSC by default installed with PowerShell - disable tests on these platforms + # if ((Get-Module -ListAvailable PSDesiredStateConfiguration) -and $PSVersionTable.PSVersion.Major -ge 4 -and ((GetPesterOS) -eq 'Windows')) { -# Describe 'Analyzing coverage of a DSC configuration' { -# BeforeAll { -# $root = (Get-PSDrive TestDrive).Root + # Describe 'Analyzing coverage of a DSC configuration' { + # BeforeAll { + # $root = (Get-PSDrive TestDrive).Root -# $null = New-Item -Path $root\TestScriptWithConfiguration.ps1 -ItemType File -ErrorAction SilentlyContinue + # $null = New-Item -Path $root\TestScriptWithConfiguration.ps1 -ItemType File -ErrorAction SilentlyContinue -# Set-Content -Path $root\TestScriptWithConfiguration.ps1 -Value @' -# $line1 = $true # Triggers breakpoint -# $line2 = $true # Triggers breakpoint + # Set-Content -Path $root\TestScriptWithConfiguration.ps1 -Value @' + # $line1 = $true # Triggers breakpoint + # $line2 = $true # Triggers breakpoint -# configuration MyTestConfig # does NOT trigger breakpoint -# { -# Import-DscResource -ModuleName PSDesiredStateConfiguration # Triggers breakpoint in PowerShell v5 but not in v4 + # configuration MyTestConfig # does NOT trigger breakpoint + # { + # Import-DscResource -ModuleName PSDesiredStateConfiguration # Triggers breakpoint in PowerShell v5 but not in v4 -# Node localhost # Triggers breakpoint -# { -# WindowsFeature XPSViewer # Triggers breakpoint -# { -# Name = 'XPS-Viewer' # does NOT trigger breakpoint -# Ensure = 'Present' # does NOT trigger breakpoint -# } -# } + # Node localhost # Triggers breakpoint + # { + # WindowsFeature XPSViewer # Triggers breakpoint + # { + # Name = 'XPS-Viewer' # does NOT trigger breakpoint + # Ensure = 'Present' # does NOT trigger breakpoint + # } + # } -# return # does NOT trigger breakpoint + # return # does NOT trigger breakpoint -# $doesNotExecute = $true # Triggers breakpoint -# } + # $doesNotExecute = $true # Triggers breakpoint + # } -# $line3 = $true # Triggers breakpoint + # $line3 = $true # Triggers breakpoint -# return # does NOT trigger breakpoint + # return # does NOT trigger breakpoint -# $doesnotexecute = $true # Triggers breakpoint -# '@ + # $doesnotexecute = $true # Triggers breakpoint + # '@ -# Enter-CoverageAnalysis -CodeCoverage "$root\TestScriptWithConfiguration.ps1" -PesterState $testState + # Enter-CoverageAnalysis -CodeCoverage "$root\TestScriptWithConfiguration.ps1" -PesterState $testState -# #the AST does not parse Import-DscResource -ModuleName PSDesiredStateConfiguration on PowerShell 4 -# $runsInPowerShell4 = $PSVersionTable.PSVersion.Major -eq 4 + # #the AST does not parse Import-DscResource -ModuleName PSDesiredStateConfiguration on PowerShell 4 + # $runsInPowerShell4 = $PSVersionTable.PSVersion.Major -eq 4 -# if ($runsInPowerShell4) { -# $expected = 7 -# } -# else { -# $expected = 8 -# } + # if ($runsInPowerShell4) { + # $expected = 7 + # } + # else { + # $expected = 8 + # } -# @($breakpoints).Count | Should -Be $expected -Because 'it has the proper number of breakpoints defined' + # @($breakpoints).Count | Should -Be $expected -Because 'it has the proper number of breakpoints defined' -# $null = . "$root\TestScriptWithConfiguration.ps1" + # $null = . "$root\TestScriptWithConfiguration.ps1" -# $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -# } + # $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + # } -# It 'Reports the proper number of missed commands before running the configuration' { -# if ($runsInPowerShell4) { -# $expected = 4 -# } -# else { -# $expected = 5 -# } + # It 'Reports the proper number of missed commands before running the configuration' { + # if ($runsInPowerShell4) { + # $expected = 4 + # } + # else { + # $expected = 5 + # } -# $coverageReport.MissedCommands.Count | Should -Be $expected -# } -# It 'Reports the proper number of missed commands after running the configuration' { + # $coverageReport.MissedCommands.Count | Should -Be $expected + # } + # It 'Reports the proper number of missed commands after running the configuration' { -# MyTestConfig -OutputPath $root + # MyTestConfig -OutputPath $root -# $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints + # $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -# if ($runsInPowerShell4) { -# $expected = 2 -# } -# else { -# $expected = 3 -# } + # if ($runsInPowerShell4) { + # $expected = 2 + # } + # else { + # $expected = 3 + # } -# $coverageReport.MissedCommands.Count | Should -Be $expected -# } + # $coverageReport.MissedCommands.Count | Should -Be $expected + # } -# AfterAll { -# Exit-CoverageAnalysis -CommandCoverage $breakpoints -# } -# } -# } + # AfterAll { + # Exit-CoverageAnalysis -CommandCoverage $breakpoints + # } + # } + # } }