From d1c84df697388f964dd4177701de43fb2f96619c Mon Sep 17 00:00:00 2001 From: Benjamin Fuchs Date: Sat, 21 Dec 2024 16:12:17 +0100 Subject: [PATCH 1/6] Add support for excluding functions with [ExcludeFromCodeCoverageAttribute()] attribute --- src/functions/Coverage.ps1 | 88 +++++++++++++++++-- tst/functions/Coverage.Tests.ps1 | 144 +++++++++++++++++-------------- 2 files changed, 157 insertions(+), 75 deletions(-) diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index 781b9c331..ef26e79e2 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -286,28 +286,100 @@ function Get-CommandsInFile { if ($PSVersionTable.PSVersion.Major -ge 5) { # In PowerShell 5.0, dynamic keywords for DSC configurations are represented by the DynamicKeywordStatementAst - # class. They still trigger breakpoints, but are not a child class of CommandBaseAst anymore. + # class. They still trigger breakpoints, but are not a child class of CommandBaseAst anymore. # ReturnStatementAst is excluded as it's not behaving consistent. # "return" is not hit in 5.1 but fixed in a later version. Using "return 123" we get hit on 123 but not return. # See https://github.com/pester/Pester/issues/1465#issuecomment-604323645 $predicate = { - $args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or - $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or - $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ThrowStatementAst] + if ($args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or + $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or + $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ThrowStatementAst]) { + if (-not (IsExcludedByAttribute -Ast $args[0])) { + return $true + } + } } } else { - $predicate = { $args[0] -is [System.Management.Automation.Language.CommandBaseAst] } + $predicate = { + if ($args[0] -is [System.Management.Automation.Language.CommandBaseAst]) { + if (-not (IsExcludedByAttribute -Ast $args[0])) { + return $true + } + } + } } $searchNestedScriptBlocks = $true $ast.FindAll($predicate, $searchNestedScriptBlocks) } +function IsExcludedByAttribute { + param ( + [System.Management.Automation.Language.Ast] $Ast + ) + + $functionParents = @() + for ($parent = $Ast.Parent; $null -ne $parent; $parent = $parent.Parent) { + if ($parent -is [System.Management.Automation.Language.FunctionDefinitionAst]) { + $functionParents += $parent + } + } + + $parentsAttributeNames = @() + foreach ($functionParent in $functionParents) { + $paramBlock = $functionParent.Body.ParamBlock + if ($null -ne $paramBlock -and $paramBlock.Attributes) { + $parentsAttributeNames += $paramBlock.Attributes.TypeName.FullName + } + } + + foreach ($parentAttributeName in $parentsAttributeNames) { + if ($parentAttributeName -match 'ExcludeFromCodeCoverageAttribute$') { + if ($parentAttributeName -eq 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') { + return $true + } + + $namespaces = Get-NamespacesFromAstTopParent -Ast $Ast + if ($namespaces) { + foreach ($namespace in $namespaces) { + $fullyQualifiedName = "$namespace.$parentAttributeName" + if ($fullyQualifiedName -eq 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') { + return $true + } + } + } + } + } + + return $false +} + +function Get-NamespacesFromAstTopParent { + param ( + [System.Management.Automation.Language.Ast] $Ast + ) + + $namespaces = @() + $topParent = Get-AstTopParent -Ast $Ast + + if ($null -ne $topParent) { + $usingStatements = $topParent.FindAll({ + param ($node) $node -is [System.Management.Automation.Language.UsingStatementAst] -and $node.UsingStatementKind -eq 'Namespace' + }, $true) + + foreach ($usingStatement in $usingStatements) { + $namespaces += $usingStatement.Name.Value + } + } + + return $namespaces +} + function Test-CoverageOverlapsCommand { param ([object] $CoverageInfo, [System.Management.Automation.Language.Ast] $Command) diff --git a/tst/functions/Coverage.Tests.ps1 b/tst/functions/Coverage.Tests.ps1 index b108745fb..d801f4463 100644 --- a/tst/functions/Coverage.Tests.ps1 +++ b/tst/functions/Coverage.Tests.ps1 @@ -23,6 +23,8 @@ InPesterModuleScope { $null = New-Item -Path $testScriptPath -ItemType File -ErrorAction SilentlyContinue Set-Content -Path $testScriptPath -Value @' + using namespace System.Diagnostics.CodeAnalysis + function FunctionOne { function NestedFunction @@ -47,6 +49,14 @@ InPesterModuleScope { 'I am function two. I never get called.' } + function FunctionThree + { + [ExcludeFromCodeCoverageAttribute(Justification = 'I am not covered')] + param () + + 'I am function three. I never get called.' + } + FunctionOne '@ @@ -288,42 +298,42 @@ InPesterModuleScope { - + - + - + - + - + - + - + - + @@ -367,21 +377,21 @@ InPesterModuleScope { - - - + + - - - - - - - - - - + + + + + + + + + + + @@ -480,42 +490,42 @@ InPesterModuleScope { - + - + - + - + - + - + - + - + @@ -559,21 +569,21 @@ InPesterModuleScope { - - - + + - - - - - - - - - - + + + + + + + + + + + @@ -675,61 +685,61 @@ InPesterModuleScope { - - - - + + + + - + - + - + - + - + - - + + - - - + + - - - - - - - - - - + + + + + + + + + + + Date: Sun, 29 Dec 2024 13:43:27 +0100 Subject: [PATCH 2/6] Refactor coverage exclusion logic --- src/functions/Coverage.ps1 | 100 +++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 44 deletions(-) diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index ef26e79e2..3a4617fe0 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -292,25 +292,19 @@ function Get-CommandsInFile { # "return" is not hit in 5.1 but fixed in a later version. Using "return 123" we get hit on 123 but not return. # See https://github.com/pester/Pester/issues/1465#issuecomment-604323645 $predicate = { - if ($args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or - $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or - $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ThrowStatementAst]) { - if (-not (IsExcludedByAttribute -Ast $args[0])) { - return $true - } - } + $args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or + $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or + $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or + $args[0] -is [System.Management.Automation.Language.ThrowStatementAst] -and + -not (IsExcludedByAttribute -Ast $args[0] -TargetAttribute 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') } } else { $predicate = { - if ($args[0] -is [System.Management.Automation.Language.CommandBaseAst]) { - if (-not (IsExcludedByAttribute -Ast $args[0])) { - return $true - } - } + $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -and + -not (IsExcludedByAttribute -Ast $args[0] -TargetAttribute 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') } } @@ -320,37 +314,40 @@ function Get-CommandsInFile { function IsExcludedByAttribute { param ( - [System.Management.Automation.Language.Ast] $Ast + [System.Management.Automation.Language.Ast] $Ast, + [string] $TargetAttribute ) - $functionParents = @() for ($parent = $Ast.Parent; $null -ne $parent; $parent = $parent.Parent) { if ($parent -is [System.Management.Automation.Language.FunctionDefinitionAst]) { - $functionParents += $parent + if (Test-ContainsAttribute -FunctionAst $parent -TargetAttribute $TargetAttribute) { + return $true + } } } - $parentsAttributeNames = @() - foreach ($functionParent in $functionParents) { - $paramBlock = $functionParent.Body.ParamBlock - if ($null -ne $paramBlock -and $paramBlock.Attributes) { - $parentsAttributeNames += $paramBlock.Attributes.TypeName.FullName - } - } + return $false +} - foreach ($parentAttributeName in $parentsAttributeNames) { - if ($parentAttributeName -match 'ExcludeFromCodeCoverageAttribute$') { - if ($parentAttributeName -eq 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') { - return $true - } +function Test-ContainsAttribute { + param ( + [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst, + [string] $TargetAttribute + ) - $namespaces = Get-NamespacesFromAstTopParent -Ast $Ast - if ($namespaces) { - foreach ($namespace in $namespaces) { - $fullyQualifiedName = "$namespace.$parentAttributeName" - if ($fullyQualifiedName -eq 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') { - return $true - } + $AttributeNames = Get-AttributeNames -FunctionAst $FunctionAst + + foreach ($attributeName in $AttributeNames) { + if ($attributeName -eq $TargetAttribute) { + return $true + } + + if ($attributeName.Split('.')[-1] -eq $TargetAttribute.Split('.')[-1]) { + $Namespaces = Get-NamespacesFromAstTopParent -Ast $FunctionAst + foreach ($namespace in $Namespaces) { + $fullyQualifiedName = "$namespace.$attributeName" + if ($fullyQualifiedName -eq $TargetAttribute) { + return $true } } } @@ -359,6 +356,19 @@ function IsExcludedByAttribute { return $false } +function Get-AttributeNames { + param ( + [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst + ) + + $paramBlock = $FunctionAst.Body.ParamBlock + if ($null -ne $paramBlock -and $paramBlock.Attributes) { + return $paramBlock.Attributes.TypeName.FullName + } + + return @() +} + function Get-NamespacesFromAstTopParent { param ( [System.Management.Automation.Language.Ast] $Ast @@ -367,14 +377,16 @@ function Get-NamespacesFromAstTopParent { $namespaces = @() $topParent = Get-AstTopParent -Ast $Ast - if ($null -ne $topParent) { - $usingStatements = $topParent.FindAll({ - param ($node) $node -is [System.Management.Automation.Language.UsingStatementAst] -and $node.UsingStatementKind -eq 'Namespace' - }, $true) + if ($null -eq $topParent) { + return @() + } - foreach ($usingStatement in $usingStatements) { - $namespaces += $usingStatement.Name.Value - } + $usingStatements = $topParent.FindAll({ + param ($node) $node -is [System.Management.Automation.Language.UsingStatementAst] -and $node.UsingStatementKind -eq 'Namespace' + }, $true) + + foreach ($usingStatement in $usingStatements) { + $namespaces += $usingStatement.Name.Value } return $namespaces From e800b90f9ac619c778cf60569c0126d656b65046 Mon Sep 17 00:00:00 2001 From: Benjamin Fuchs Date: Sun, 12 Jan 2025 16:03:13 +0100 Subject: [PATCH 3/6] Improving Test-ContainsAttribute and minor review improvements --- src/functions/Coverage.ps1 | 57 ++++++++------------------------------ 1 file changed, 11 insertions(+), 46 deletions(-) diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index 3a4617fe0..8e2f669d4 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -319,8 +319,8 @@ function IsExcludedByAttribute { ) for ($parent = $Ast.Parent; $null -ne $parent; $parent = $parent.Parent) { - if ($parent -is [System.Management.Automation.Language.FunctionDefinitionAst]) { - if (Test-ContainsAttribute -FunctionAst $parent -TargetAttribute $TargetAttribute) { + if ($parent -is [System.Management.Automation.Language.ScriptBlockAst]) { + if (Test-ContainsAttribute -ScriptBlockAst $parent -TargetAttribute $TargetAttribute) { return $true } } @@ -331,65 +331,30 @@ function IsExcludedByAttribute { function Test-ContainsAttribute { param ( - [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst, + [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst, [string] $TargetAttribute ) - $AttributeNames = Get-AttributeNames -FunctionAst $FunctionAst - - foreach ($attributeName in $AttributeNames) { - if ($attributeName -eq $TargetAttribute) { + $attributes = Get-Attributes -ScriptBlockAst $ScriptBlockAst + foreach ($attribute in $attributes) { + $type = $attribute.TypeName.GetReflectionType() + if ($null -ne $type -and $type.FullName -eq $TargetAttribute) { return $true } - - if ($attributeName.Split('.')[-1] -eq $TargetAttribute.Split('.')[-1]) { - $Namespaces = Get-NamespacesFromAstTopParent -Ast $FunctionAst - foreach ($namespace in $Namespaces) { - $fullyQualifiedName = "$namespace.$attributeName" - if ($fullyQualifiedName -eq $TargetAttribute) { - return $true - } - } - } } return $false } -function Get-AttributeNames { +function Get-Attributes { param ( - [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst + [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) - $paramBlock = $FunctionAst.Body.ParamBlock + $paramBlock = $ScriptBlockAst.ParamBlock if ($null -ne $paramBlock -and $paramBlock.Attributes) { - return $paramBlock.Attributes.TypeName.FullName - } - - return @() -} - -function Get-NamespacesFromAstTopParent { - param ( - [System.Management.Automation.Language.Ast] $Ast - ) - - $namespaces = @() - $topParent = Get-AstTopParent -Ast $Ast - - if ($null -eq $topParent) { - return @() + return $paramBlock.Attributes } - - $usingStatements = $topParent.FindAll({ - param ($node) $node -is [System.Management.Automation.Language.UsingStatementAst] -and $node.UsingStatementKind -eq 'Namespace' - }, $true) - - foreach ($usingStatement in $usingStatements) { - $namespaces += $usingStatement.Name.Value - } - - return $namespaces } function Test-CoverageOverlapsCommand { From 88e058c7ecbdbcf761dd8745649b17eba0309da6 Mon Sep 17 00:00:00 2001 From: Benjamin Fuchs Date: Sun, 12 Jan 2025 22:06:17 +0100 Subject: [PATCH 4/6] Replacing FindAll with AstVisitor --- src/csharp/Pester/CoverageLocationVisitor.cs | 72 +++++++++++++++++++ src/functions/Coverage.ps1 | 74 +------------------- 2 files changed, 75 insertions(+), 71 deletions(-) create mode 100644 src/csharp/Pester/CoverageLocationVisitor.cs diff --git a/src/csharp/Pester/CoverageLocationVisitor.cs b/src/csharp/Pester/CoverageLocationVisitor.cs new file mode 100644 index 000000000..05aa3194d --- /dev/null +++ b/src/csharp/Pester/CoverageLocationVisitor.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Management.Automation.Language; + +namespace Pester +{ + public class CoverageLocationVisitor : AstVisitor2 + { + public readonly List CoverageLocations = new(); + + public override AstVisitAction VisitScriptBlock(ScriptBlockAst scriptBlockAst) + { + if (scriptBlockAst.ParamBlock?.Attributes != null) + { + foreach (var attribute in scriptBlockAst.ParamBlock.Attributes) + { + if (attribute.TypeName.GetReflectionType() == typeof(ExcludeFromCodeCoverageAttribute)) + { + return AstVisitAction.SkipChildren; + } + } + } + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitCommand(CommandAst commandAst) + { + CoverageLocations.Add(commandAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitCommandExpression(CommandExpressionAst commandExpressionAst) + { + CoverageLocations.Add(commandExpressionAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitDynamicKeywordStatement(DynamicKeywordStatementAst dynamicKeywordStatementAst) + { + CoverageLocations.Add(dynamicKeywordStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitBreakStatement(BreakStatementAst breakStatementAst) + { + CoverageLocations.Add(breakStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitContinueStatement(ContinueStatementAst continueStatementAst) + { + CoverageLocations.Add(continueStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitExitStatement(ExitStatementAst exitStatementAst) + { + CoverageLocations.Add(exitStatementAst); + return AstVisitAction.Continue; + } + + public override AstVisitAction VisitThrowStatement(ThrowStatementAst throwStatementAst) + { + CoverageLocations.Add(throwStatementAst); + return AstVisitAction.Continue; + } + + // ReturnStatementAst is excluded as it's not behaving consistent. + // "return" is not hit in 5.1 but fixed in a later version. Using "return 123" we get hit on 123 but not return. + // See https://github.com/pester/Pester/issues/1465#issuecomment-604323645 + } +} diff --git a/src/functions/Coverage.ps1 b/src/functions/Coverage.ps1 index 8e2f669d4..8d836114b 100644 --- a/src/functions/Coverage.ps1 +++ b/src/functions/Coverage.ps1 @@ -284,77 +284,9 @@ function Get-CommandsInFile { $tokens = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref] $tokens, [ref] $errors) - if ($PSVersionTable.PSVersion.Major -ge 5) { - # In PowerShell 5.0, dynamic keywords for DSC configurations are represented by the DynamicKeywordStatementAst - # class. They still trigger breakpoints, but are not a child class of CommandBaseAst anymore. - - # ReturnStatementAst is excluded as it's not behaving consistent. - # "return" is not hit in 5.1 but fixed in a later version. Using "return 123" we get hit on 123 but not return. - # See https://github.com/pester/Pester/issues/1465#issuecomment-604323645 - $predicate = { - $args[0] -is [System.Management.Automation.Language.DynamicKeywordStatementAst] -or - $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -or - $args[0] -is [System.Management.Automation.Language.BreakStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ContinueStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ExitStatementAst] -or - $args[0] -is [System.Management.Automation.Language.ThrowStatementAst] -and - -not (IsExcludedByAttribute -Ast $args[0] -TargetAttribute 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') - } - } - else { - $predicate = { - $args[0] -is [System.Management.Automation.Language.CommandBaseAst] -and - -not (IsExcludedByAttribute -Ast $args[0] -TargetAttribute 'System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute') - } - } - - $searchNestedScriptBlocks = $true - $ast.FindAll($predicate, $searchNestedScriptBlocks) -} - -function IsExcludedByAttribute { - param ( - [System.Management.Automation.Language.Ast] $Ast, - [string] $TargetAttribute - ) - - for ($parent = $Ast.Parent; $null -ne $parent; $parent = $parent.Parent) { - if ($parent -is [System.Management.Automation.Language.ScriptBlockAst]) { - if (Test-ContainsAttribute -ScriptBlockAst $parent -TargetAttribute $TargetAttribute) { - return $true - } - } - } - - return $false -} - -function Test-ContainsAttribute { - param ( - [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst, - [string] $TargetAttribute - ) - - $attributes = Get-Attributes -ScriptBlockAst $ScriptBlockAst - foreach ($attribute in $attributes) { - $type = $attribute.TypeName.GetReflectionType() - if ($null -ne $type -and $type.FullName -eq $TargetAttribute) { - return $true - } - } - - return $false -} - -function Get-Attributes { - param ( - [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst - ) - - $paramBlock = $ScriptBlockAst.ParamBlock - if ($null -ne $paramBlock -and $paramBlock.Attributes) { - return $paramBlock.Attributes - } + $visitor = [Pester.CoverageLocationVisitor]::new() + $ast.Visit($visitor) + return $visitor.CoverageLocations } function Test-CoverageOverlapsCommand { From 82ef8687aa70c406ccf5ed9fdee1f400a87649e1 Mon Sep 17 00:00:00 2001 From: Benjamin Fuchs Date: Mon, 13 Jan 2025 00:23:53 +0100 Subject: [PATCH 5/6] Updating tests --- tst/functions/Coverage.Tests.ps1 | 219 ++++++++++++++++++++----------- 1 file changed, 142 insertions(+), 77 deletions(-) diff --git a/tst/functions/Coverage.Tests.ps1 b/tst/functions/Coverage.Tests.ps1 index d801f4463..20c58bab4 100644 --- a/tst/functions/Coverage.Tests.ps1 +++ b/tst/functions/Coverage.Tests.ps1 @@ -23,8 +23,6 @@ InPesterModuleScope { $null = New-Item -Path $testScriptPath -ItemType File -ErrorAction SilentlyContinue Set-Content -Path $testScriptPath -Value @' - using namespace System.Diagnostics.CodeAnalysis - function FunctionOne { function NestedFunction @@ -49,14 +47,6 @@ InPesterModuleScope { 'I am function two. I never get called.' } - function FunctionThree - { - [ExcludeFromCodeCoverageAttribute(Justification = 'I am not covered')] - param () - - 'I am function three. I never get called.' - } - FunctionOne '@ @@ -298,42 +288,42 @@ InPesterModuleScope { - + - + - + - + - + - + - + - + @@ -377,21 +367,21 @@ InPesterModuleScope { - - + + + - - - - - - - - + + + + + + + + + + - - - @@ -490,42 +480,42 @@ InPesterModuleScope { - + - + - + - + - + - + - + - + @@ -569,21 +559,21 @@ InPesterModuleScope { - - + + + - - - - - - - - + + + + + + + + + + - - - @@ -685,61 +675,61 @@ InPesterModuleScope { + - - - - + + + - + - + - + - + - + - - + + - - + + + - - - - - - - - + + + + + + + + + + - - - Date: Wed, 15 Jan 2025 22:43:57 +0100 Subject: [PATCH 6/6] Adding description to new class and high level test --- src/csharp/Pester/CoverageLocationVisitor.cs | 8 ++ tst/functions/Coverage.Tests.ps1 | 112 ++++++++++++++----- 2 files changed, 89 insertions(+), 31 deletions(-) diff --git a/src/csharp/Pester/CoverageLocationVisitor.cs b/src/csharp/Pester/CoverageLocationVisitor.cs index 05aa3194d..46e1362f0 100644 --- a/src/csharp/Pester/CoverageLocationVisitor.cs +++ b/src/csharp/Pester/CoverageLocationVisitor.cs @@ -4,6 +4,14 @@ namespace Pester { + /// + /// A visitor class for traversing the PowerShell AST to collect coverage-relevant locations. + /// This replaces predicate-based filtering with a centralized, extensible approach. + /// + /// Advantages: + /// - Efficiently skips nodes with attributes like [ExcludeFromCodeCoverage]. + /// - Simplifies logic by handling each AST type in dedicated methods. + /// public class CoverageLocationVisitor : AstVisitor2 { public readonly List CoverageLocations = new(); diff --git a/tst/functions/Coverage.Tests.ps1 b/tst/functions/Coverage.Tests.ps1 index 20c58bab4..115eb7780 100644 --- a/tst/functions/Coverage.Tests.ps1 +++ b/tst/functions/Coverage.Tests.ps1 @@ -1284,68 +1284,70 @@ InPesterModuleScope { } } - Describe 'CoverageLocationVisitor' { + Describe 'Coverage Location Visitor' { BeforeAll { $testScript = @' using namespace System.Diagnostics.CodeAnalysis - function FunctionOne { - Write-Host "I am functionOne" # 1 location + function FunctionIncluded { + "I am included" } - function FunctionTwo { + function FunctionExcluded { [ExcludeFromCodeCoverageAttribute()] param() - Write-Debug "I am functionTwo" # not collected + "I am not included" } -'@ + FunctionIncluded + FunctionExcluded + +'@ $tokens = $null $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseInput($testScript, [ref]$tokens, [ref]$errors) } - Context 'VisitScriptBlock' { - It 'Skips children with ExcludeFromCodeCoverageAttribute' { + Context 'Collect coverage locations' { + BeforeAll { $visitor = [Pester.CoverageLocationVisitor]::new() $ast.Visit($visitor) + } + It 'Skips excluded script blocks' { $excludedCommand = $visitor.CoverageLocations | Where-Object { - $_ -is [System.Management.Automation.Language.CommandAst] -and - $_.CommandElements[0].Value -eq "Write-Debug" + $_ -is [System.Management.Automation.Language.CommandExpressionAst] -and + $_.Expression.Value -eq "I am not included" } - $excludedCommand | Should -BeNullOrEmpty -Because "Commands with the ExcludeFromCodeCoverageAttribute should be skipped." + $excludedCommand.Count | Should -Be 0 -Because "Command in excluded script blocks should not be collected." } - It 'Processes children without ExcludeFromCodeCoverageAttribute' { - $visitor = [Pester.CoverageLocationVisitor]::new() - $ast.Visit($visitor) - + It 'Processes included script blocks' { $includedCommand = $visitor.CoverageLocations | Where-Object { - $_ -is [System.Management.Automation.Language.CommandAst] -and - $_.CommandElements[0].Value -eq "Write-Host" + $_ -is [System.Management.Automation.Language.CommandExpressionAst] -and + $_.Expression.Value -eq "I am included" } - $includedCommand.Count | Should -Be 1 -Because "Commands without the attribute should be included." + $includedCommand.Count | Should -Be 1 -Because "Command in included script blocks should be collected." } } - Context 'Visit Other AST Types' { - It 'Collects Break, Continue, Exit, and Throw statements' { + Context 'Collect coverage locations for other AST types' { + It 'Collects all relevant AST types' { $script = @' - foreach ($i in 1..10) { # 1 location - break # 1 location - continue # 1 location - if ($i -eq 5) { # 1 location - throw # 1 location - } - if ($i -eq 7) { # 1 location - exit # 1 location - } - return # not collected - } + foreach ($i in 1..10) { # 1 location + break # 1 location + continue # 1 location + if ($i -eq 5) { # 1 location + throw # 1 location + } + if ($i -eq 7) { # 1 location + exit # 1 location + } + return # not collected + } '@ $tokens = $null $errors = $null @@ -1357,6 +1359,54 @@ InPesterModuleScope { $visitor.CoverageLocations.Count | Should -Be 7 -Because "Break, Continue, Throw, and Exit statements should be collected." } } + + Context 'Coverage analysis with exclusion using ' -Foreach @( + @{ UseBreakpoints = $true; Description = "With breakpoints" } + @{ UseBreakpoints = $false; Description = "Profiler-based coverage collection" } + ) { + BeforeAll { + $root = (Get-PSDrive TestDrive).Root + $testScriptPath = Join-Path -Path $root -ChildPath TestScript.ps1 + Set-Content -Path $testScriptPath -Value $testScript + + $breakpoints = Enter-CoverageAnalysis -CodeCoverage @{ Path = $testScriptPath } -UseBreakpoints $UseBreakpoints + + @($breakpoints).Count | Should -Be 3 -Because 'The correct number of breakpoints should be defined.' + + if ($UseBreakpoints) { + & $testScriptPath + } + else { + $patched, $tracer = Start-TraceScript $breakpoints + try { & $testScriptPath } finally { Stop-TraceScript -Patched $patched } + $measure = $tracer.Hits + } + + $coverageReport = Get-CoverageReport -CommandCoverage $breakpoints -Measure $measure + } + + It 'Correctly reports executed commands' { + $coverageReport.NumberOfCommandsExecuted | Should -Be 3 -Because 'The executed commands count should match.' + } + + It 'Correctly reports analyzed commands' { + $coverageReport.NumberOfCommandsAnalyzed | Should -Be 3 -Because 'All commands should be analyzed.' + } + + It 'Correctly reports missed commands' { + $coverageReport.MissedCommands.Count | Should -Be 0 -Because 'No command should be missed.' + } + + It 'Correctly reports hit commands' { + $coverageReport.HitCommands.Count | Should -Be 3 -Because 'Three commands should be hit.' + } + + AfterAll { + if ($UseBreakpoints) { + Exit-CoverageAnalysis -CommandCoverage $breakpoints + } + } + } } # Describe 'Stripping common parent paths' {