From 44fd54a0f4a78f6791ce616d1d3bcbb759f4bbbe Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:52:40 -0600 Subject: [PATCH 01/19] Initial commit. This adds support for tag exprssions on Scopes assigned to bindings. --- Reqnroll.Parser/Reqnroll.Parser.csproj | 4 ++ Reqnroll/Bindings/BindingScope.cs | 21 ++++------- .../Discovery/BindingSourceProcessor.cs | 9 +++-- .../Discovery/ReqnrollTagExpressionParser.cs | 36 ++++++++++++++++++ .../RuntimeBindingSourceProcessor.cs | 3 +- .../Provider/BindingProviderService.cs | 4 +- .../Cucumber/CucumberMessageFactory.cs | 2 +- .../DefaultDependencyProvider.cs | 4 ++ Reqnroll/Reqnroll.csproj | 6 +++ Reqnroll/Reqnroll.nuspec | 1 + .../CucumberMessagesValidator.cs | 18 ++++----- .../BindingSourceProcessorStub.cs | 3 +- .../Bindings/BindingFactoryTests.cs | 5 ++- .../CucumberExpressionIntegrationTests.cs | 14 ++++--- .../StepDefinitionMatchServiceTest.cs | 37 ++++++++++++++++--- .../Reqnroll.RuntimeTests.csproj | 6 +++ 16 files changed, 128 insertions(+), 45 deletions(-) create mode 100644 Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs diff --git a/Reqnroll.Parser/Reqnroll.Parser.csproj b/Reqnroll.Parser/Reqnroll.Parser.csproj index 83caa23b9..207d5ca07 100644 --- a/Reqnroll.Parser/Reqnroll.Parser.csproj +++ b/Reqnroll.Parser/Reqnroll.Parser.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/Reqnroll/Bindings/BindingScope.cs b/Reqnroll/Bindings/BindingScope.cs index 6ba2c12e8..9d029a57f 100644 --- a/Reqnroll/Bindings/BindingScope.cs +++ b/Reqnroll/Bindings/BindingScope.cs @@ -1,38 +1,31 @@ using System; using System.Linq; +using Cucumber.TagExpressions; namespace Reqnroll.Bindings { public class BindingScope { - public string Tag { get; private set; } + public string Tag { get => _tagExpression.ToString(); } public string FeatureTitle { get; private set; } public string ScenarioTitle { get; private set; } - - public BindingScope(string tag, string featureTitle, string scenarioTitle) + private readonly ITagExpression _tagExpression; + public BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle) { - Tag = RemoveLeadingAt(tag); + _tagExpression = tagExpression; FeatureTitle = featureTitle; ScenarioTitle = scenarioTitle; } - private string RemoveLeadingAt(string tag) - { - if (tag == null || !tag.StartsWith("@")) - return tag; - - return tag.Substring(1); // remove leading "@" - } - public bool Match(StepContext stepContext, out int scopeMatches) { scopeMatches = 0; - var tags = stepContext.Tags; + var tags = stepContext.Tags.Select(t => "@" + t).ToList(); if (Tag != null) { - if (!tags.Contains(Tag)) + if (!_tagExpression.Evaluate(tags)) return false; scopeMatches++; diff --git a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs index 79dd1b770..4c4a53816 100644 --- a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs +++ b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs @@ -3,20 +3,23 @@ using System.Linq; using Reqnroll.Bindings.Reflection; using Reqnroll.PlatformCompatibility; +using Cucumber.TagExpressions; namespace Reqnroll.Bindings.Discovery { public abstract class BindingSourceProcessor : IBindingSourceProcessor { private readonly IBindingFactory _bindingFactory; + private readonly ITagExpressionParser _tagExpressionParser; private BindingSourceType _currentBindingSourceType = null; private BindingScope[] _typeScopes = null; private readonly List _stepDefinitionBindingBuilders = new(); - protected BindingSourceProcessor(IBindingFactory bindingFactory) + protected BindingSourceProcessor(IBindingFactory bindingFactory, ITagExpressionParser tagExpressionParser) { _bindingFactory = bindingFactory; + _tagExpressionParser = tagExpressionParser; } public bool CanProcessTypeAttribute(string attributeTypeName) @@ -75,7 +78,7 @@ public virtual void BuildingCompleted() private IEnumerable GetScopes(IEnumerable attributes) { return attributes.Where(attr => attr.AttributeType.TypeEquals(typeof(ScopeAttribute))) - .Select(attr => new BindingScope(attr.TryGetAttributeValue("Tag"), attr.TryGetAttributeValue("Feature"), attr.TryGetAttributeValue("Scenario"))); + .Select(attr => new BindingScope(_tagExpressionParser.Parse(attr.TryGetAttributeValue("Tag")), attr.TryGetAttributeValue("Feature"), attr.TryGetAttributeValue("Scenario"))); } private bool IsBindingType(BindingSourceType bindingSourceType) @@ -156,7 +159,7 @@ private void ProcessHookAttribute(BindingSourceMethod bindingSourceMethod, Bindi string[] tags = GetTagsDefinedOnBindingAttribute(hookAttribute); if (tags != null) - scopes = scopes.Concat(tags.Select(t => new BindingScope(t, null, null))); + scopes = scopes.Concat(tags.Select(t => new BindingScope(_tagExpressionParser.Parse(t), null, null))); ApplyForScope(scopes.ToArray(), scope => ProcessHookAttribute(bindingSourceMethod, hookAttribute, scope)); diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs new file mode 100644 index 000000000..95da8a2a8 --- /dev/null +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs @@ -0,0 +1,36 @@ +using Cucumber.TagExpressions; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Reqnroll.Bindings.Discovery +{ + public class ReqnrollTagExpressionParser(ITagExpressionParser tagExpressionParser) : ITagExpressionParser + { + public ITagExpression Parse(string tagExpression) + { + return Rewrite(tagExpressionParser.Parse(tagExpression)); + } + + private ITagExpression Rewrite(ITagExpression expression) + { + return expression switch + { + NullExpression nullExpression => nullExpression, + NotNode notNode => new NotNode(Rewrite(notNode.Operand)), + BinaryOpNode binaryOpNode => new BinaryOpNode(binaryOpNode.Op, Rewrite(binaryOpNode.Left), Rewrite(binaryOpNode.Right)), + LiteralNode literalNode => new LiteralNode(PrefixLiteral(literalNode.Name)), + _ => throw new NotSupportedException($"Unsupported tag expression type: {expression.GetType().FullName}"), + }; + } + + private string PrefixLiteral(string name) + { + if (name.IsNullOrEmpty() ) + return name; + if (name.StartsWith("@")) + return name; + return "@" + name; + } + } +} diff --git a/Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs b/Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs index 3e7372153..82f93afc4 100644 --- a/Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs +++ b/Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs @@ -1,4 +1,5 @@ using Reqnroll.Tracing; +using Cucumber.TagExpressions; namespace Reqnroll.Bindings.Discovery { @@ -12,7 +13,7 @@ public class RuntimeBindingSourceProcessor : BindingSourceProcessor, IRuntimeBin private readonly IBindingRegistry _bindingRegistry; private readonly ITestTracer _testTracer; - public RuntimeBindingSourceProcessor(IBindingFactory bindingFactory, IBindingRegistry bindingRegistry, ITestTracer testTracer) : base(bindingFactory) + public RuntimeBindingSourceProcessor(IBindingFactory bindingFactory, IBindingRegistry bindingRegistry, ITestTracer testTracer, ITagExpressionParser tagExpressionParser) : base(bindingFactory, tagExpressionParser) { _bindingRegistry = bindingRegistry; _testTracer = testTracer; diff --git a/Reqnroll/Bindings/Provider/BindingProviderService.cs b/Reqnroll/Bindings/Provider/BindingProviderService.cs index 520d5cfad..c9f40b9a2 100644 --- a/Reqnroll/Bindings/Provider/BindingProviderService.cs +++ b/Reqnroll/Bindings/Provider/BindingProviderService.cs @@ -1,4 +1,5 @@ -using Reqnroll.Bindings.Discovery; +using Cucumber.TagExpressions; +using Reqnroll.Bindings.Discovery; using Reqnroll.Bindings.Provider.Data; using Reqnroll.Bindings.Reflection; using Reqnroll.BoDi; @@ -188,6 +189,7 @@ public override void RegisterGlobalContainerDefaults(ObjectContainer container) base.RegisterGlobalContainerDefaults(container); container.RegisterTypeAs(); container.RegisterTypeAs(); + container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser(new TagExpressionParser())); } } diff --git a/Reqnroll/Formatters/PayloadProcessing/Cucumber/CucumberMessageFactory.cs b/Reqnroll/Formatters/PayloadProcessing/Cucumber/CucumberMessageFactory.cs index a27af0cf2..2254a14b9 100644 --- a/Reqnroll/Formatters/PayloadProcessing/Cucumber/CucumberMessageFactory.cs +++ b/Reqnroll/Formatters/PayloadProcessing/Cucumber/CucumberMessageFactory.cs @@ -230,7 +230,7 @@ public virtual Hook ToHook(IHookBinding hookBinding, IIdGenerator iDGenerator) iDGenerator.GetNewId(), null, sourceRef, - hookBinding.IsScoped ? $"@{hookBinding.BindingScope.Tag}" : null, + hookBinding.IsScoped ? hookBinding.BindingScope.Tag : null, ToHookType(hookBinding) ); return result; diff --git a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs index 4724766ad..3ebf9bbcb 100644 --- a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs +++ b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs @@ -1,3 +1,4 @@ +using Cucumber.TagExpressions; using Gherkin.CucumberMessages; using Reqnroll.Analytics; using Reqnroll.Analytics.AppInsights; @@ -28,6 +29,7 @@ using Reqnroll.Tracing; using Reqnroll.Utils; using System; +using System.ComponentModel; namespace Reqnroll.Infrastructure { @@ -134,6 +136,8 @@ public virtual void RegisterGlobalContainerDefaults(ObjectContainer container) container.RegisterTypeAs(); container.RegisterFactoryAs(() => container.Resolve()); container.RegisterTypeAs(); + + var _ = container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser(new TagExpressionParser())).InstancePerDependency; } public virtual void RegisterTestThreadContainerDefaults(ObjectContainer testThreadContainer) diff --git a/Reqnroll/Reqnroll.csproj b/Reqnroll/Reqnroll.csproj index 7699ff08c..d1de21fb0 100644 --- a/Reqnroll/Reqnroll.csproj +++ b/Reqnroll/Reqnroll.csproj @@ -52,6 +52,12 @@ + + + ..\..\cucumber\tag-expressions\dotnet\TagExpressions\bin\Debug\netstandard2.0\Cucumber.TagExpressions.dll + + + diff --git a/Reqnroll/Reqnroll.nuspec b/Reqnroll/Reqnroll.nuspec index 5fdacb80e..9b397e42b 100644 --- a/Reqnroll/Reqnroll.nuspec +++ b/Reqnroll/Reqnroll.nuspec @@ -37,6 +37,7 @@ + diff --git a/Tests/Reqnroll.Formatters.Tests/CucumberMessagesValidator.cs b/Tests/Reqnroll.Formatters.Tests/CucumberMessagesValidator.cs index 85a30a47a..09de4c01a 100644 --- a/Tests/Reqnroll.Formatters.Tests/CucumberMessagesValidator.cs +++ b/Tests/Reqnroll.Formatters.Tests/CucumberMessagesValidator.cs @@ -547,17 +547,15 @@ private EquivalencyAssertionOptions ArrangeFluentAssertionOptions(Equivale actualList.Should().HaveCountGreaterThanOrEqualTo(expectedList.Count, "actual collection should have at least as many items as expected"); - // Impossible to compare individual Hook messages (Ids aren't comparable, the Source references aren't compatible, - // and the Scope tags won't line up because the CCK uses tag expressions and RnR does not support them) + // Difficult to compare individual Hook messages (Ids aren't comparable, the Source references aren't compatible, // and After Hook execution ordering is different between Reqnroll and CCK. - /* - foreach (var expectedItem in expectedList) - { - actualList.Should().Contain(actualItem => - AssertionExtensions.Should(actualItem).BeEquivalentTo(expectedItem, "").And.Subject == actualItem, - "actual collection should contain an item equivalent to {0}", expectedItem); - } - */ + foreach (var expectedItem in expectedList) + { + actualList.Should().Contain(actualItem => + actualItem.TagExpression == expectedItem.TagExpression + && actualItem.Type == expectedItem.Type, + "actual collection should contain an item equivalent to {0}", expectedItem); + } } }) .WhenTypeIs>() diff --git a/Tests/Reqnroll.RuntimeTests/BindingSourceProcessorStub.cs b/Tests/Reqnroll.RuntimeTests/BindingSourceProcessorStub.cs index d16a93041..c7beb6e16 100644 --- a/Tests/Reqnroll.RuntimeTests/BindingSourceProcessorStub.cs +++ b/Tests/Reqnroll.RuntimeTests/BindingSourceProcessorStub.cs @@ -4,6 +4,7 @@ using Reqnroll.Bindings.CucumberExpressions; using Reqnroll.Bindings.Discovery; using Reqnroll.Configuration; +using Cucumber.TagExpressions; namespace Reqnroll.RuntimeTests { @@ -17,7 +18,7 @@ public class BindingSourceProcessorStub : BindingSourceProcessor, IRuntimeBindin public IEnumerable ValidationErrors => GeneralErrorMessages.Concat(BindingSpecificErrorMessages); - public BindingSourceProcessorStub() : base(new BindingFactory(new StepDefinitionRegexCalculator(ConfigurationLoader.GetDefault()), new CucumberExpressionStepDefinitionBindingBuilderFactory(new CucumberExpressionParameterTypeRegistry(new BindingRegistry())), new CucumberExpressionDetector())) + public BindingSourceProcessorStub() : base(new BindingFactory(new StepDefinitionRegexCalculator(ConfigurationLoader.GetDefault()), new CucumberExpressionStepDefinitionBindingBuilderFactory(new CucumberExpressionParameterTypeRegistry(new BindingRegistry())), new CucumberExpressionDetector()), new ReqnrollTagExpressionParser(new TagExpressionParser())) { } diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/BindingFactoryTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/BindingFactoryTests.cs index a5eba674b..d9698ffc7 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/BindingFactoryTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/BindingFactoryTests.cs @@ -7,6 +7,7 @@ using Reqnroll.Bindings.Reflection; using System.Linq; using Xunit; +using Reqnroll.Bindings.Discovery; namespace Reqnroll.RuntimeTests.Bindings; @@ -23,10 +24,10 @@ public void CreateStepDefinitionBindingBuilderTest(ExpressionType expressionType var cucumberExpressionStepDefinitionBindingBuilderFactory = new CucumberExpressionStepDefinitionBindingBuilderFactory(new CucumberExpressionParameterTypeRegistry(Mock.Of())); var cucumberExpressionDetector = new CucumberExpressionDetector(); var sut = new BindingFactory(stepDefinitionRegexCalculator.Object, cucumberExpressionStepDefinitionBindingBuilderFactory, cucumberExpressionDetector); - + var tagExpressionParser = new ReqnrollTagExpressionParser(new Cucumber.TagExpressions.TagExpressionParser()); var stepDefinitionType = StepDefinitionType.Given; var bindingMethod = new Mock().Object; - var bindingScope = new BindingScope("tag1", "feature1", "scenario1"); + var bindingScope = new BindingScope(tagExpressionParser.Parse("tag1"), "feature1", "scenario1"); var expressionString = "The product {string} has the price {int}$"; // Act diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs index 4b77d1970..bc03b3204 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs @@ -1,16 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; -using Reqnroll.BoDi; +using Cucumber.TagExpressions; using FluentAssertions; using Moq; using Reqnroll.Bindings; using Reqnroll.Bindings.Discovery; using Reqnroll.Bindings.Reflection; +using Reqnroll.BoDi; using Reqnroll.Infrastructure; using Reqnroll.UnitTestProvider; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; using Xunit; namespace Reqnroll.RuntimeTests.Bindings.CucumberExpressions; @@ -152,6 +153,7 @@ public override void RegisterGlobalContainerDefaults(ObjectContainer container) base.RegisterGlobalContainerDefaults(container); var stubUintTestProvider = new Mock(); container.RegisterInstanceAs(stubUintTestProvider.Object, "nunit"); + container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser(new TagExpressionParser())); } } diff --git a/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs b/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs index 2f0986df8..5d9945808 100644 --- a/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs +++ b/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs @@ -7,6 +7,8 @@ using Reqnroll.Infrastructure; using FluentAssertions; using Reqnroll.RuntimeTests.ErrorHandling; +using Reqnroll.Bindings.Discovery; +using System; namespace Reqnroll.RuntimeTests.Infrastructure { @@ -53,13 +55,20 @@ private static BindingMethod CreateBindingMethodWithObjectParam(string name = "d return new BindingMethod(new BindingType("dummy", "dummy", "dummy"), name, new IBindingParameter[] { new BindingParameter(new RuntimeBindingType(typeof(object)), "param1") }, null); } - private StepInstance CreateSimpleWhen(string text = "I do something") + private StepInstance CreateSimpleWhen(string text = "I do something", IEnumerable tags = null) { + tags = tags ?? new string[0]; var result = new StepInstance(StepDefinitionType.When, StepDefinitionKeyword.When, "When ", text, null, null, - new StepContext("MyFeature", "MyScenario", new string[0], new CultureInfo("en-US", false))); + new StepContext("MyFeature", "MyScenario", tags, new CultureInfo("en-US", false))); return result; } + private BindingScope CreateBindingScope(string tag, string featureTitle, string scenarioTitle) + { + var tagExpressionParser = new ReqnrollTagExpressionParser(new Cucumber.TagExpressions.TagExpressionParser()); + return new BindingScope(tagExpressionParser.Parse(tag), featureTitle, scenarioTitle); + } + [Fact] public void Should_GetBestMatch_succeed_when_proper_match() { @@ -76,7 +85,7 @@ public void Should_GetBestMatch_succeed_when_proper_match() public void Should_GetBestMatch_succeed_when_proper_match_and_non_matching_scopes() { whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m1"))); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m2"), new BindingScope("non-matching-tag", null, null))); + whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m2"), CreateBindingScope("non-matching-tag", null, null))); var sut = CreateSUT(); @@ -85,6 +94,22 @@ public void Should_GetBestMatch_succeed_when_proper_match_and_non_matching_scope result.Success.Should().BeTrue(); } + [Fact] + public void Should_GetBestMatch_succeed_when_proper_match_and_matching_tag_expression() + { + whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m1"))); + whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m2"), CreateBindingScope("@alpha and @beta", null, null))); + + var sut = CreateSUT(); + + var result = sut.GetBestMatch(CreateSimpleWhen("I do something", new string[] { "alpha", "beta" }), bindingCulture, out _, out _); + + result.Success.Should().BeTrue(); + result.StepBinding.Method.Name.Should().Be("m2"); + } + + + [Fact] public void Should_GetBestMatch_succeed_when_proper_match_with_parameters() { @@ -139,7 +164,7 @@ public void Should_GetBestMatch_succeed_when_proper_match_with_object_parameters [Fact] public void Should_GetBestMatch_fail_when_scope_errors_with_single_match() { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod(), new BindingScope("non-matching-tag", null, null))); + whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod(), CreateBindingScope("non-matching-tag", null, null))); var sut = CreateSUT(); @@ -152,8 +177,8 @@ public void Should_GetBestMatch_fail_when_scope_errors_with_single_match() [Fact] public void Should_GetBestMatch_fail_when_scope_errors_with_multiple_matches() { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy1"), new BindingScope("non-matching-tag", null, null))); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy2"), new BindingScope("other-non-matching-tag", null, null))); + whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy1"), CreateBindingScope("non-matching-tag", null, null))); + whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy2"), CreateBindingScope("other-non-matching-tag", null, null))); var sut = CreateSUT(); diff --git a/Tests/Reqnroll.RuntimeTests/Reqnroll.RuntimeTests.csproj b/Tests/Reqnroll.RuntimeTests/Reqnroll.RuntimeTests.csproj index fab9101ac..29491b16c 100644 --- a/Tests/Reqnroll.RuntimeTests/Reqnroll.RuntimeTests.csproj +++ b/Tests/Reqnroll.RuntimeTests/Reqnroll.RuntimeTests.csproj @@ -37,6 +37,12 @@ + + + ..\..\..\cucumber\tag-expressions\dotnet\TagExpressions\bin\Debug\netstandard2.0\Cucumber.TagExpressions.dll + + + From 416be89d1134a5d2e4e216b0cedd4a177440d56f Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:31:40 -0600 Subject: [PATCH 02/19] Fix an issue in which the BindingProviderService (as invoked OOP and via reflection by the VisualStudio Extension for Reqnroll) would provide a hardcoded prefix "@" to all Scope Tag values. This is no longer necessary with Tag Expression support. --- Reqnroll/Bindings/Provider/BindingProviderService.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Reqnroll/Bindings/Provider/BindingProviderService.cs b/Reqnroll/Bindings/Provider/BindingProviderService.cs index c9f40b9a2..4b5d8bef0 100644 --- a/Reqnroll/Bindings/Provider/BindingProviderService.cs +++ b/Reqnroll/Bindings/Provider/BindingProviderService.cs @@ -139,9 +139,7 @@ BindingScopeData GetScope(IScopedBinding scopedBinding) return new BindingScopeData { - Tag = scopedBinding.BindingScope.Tag == null - ? null - : "@" + scopedBinding.BindingScope.Tag, + Tag = scopedBinding.BindingScope.Tag, FeatureTitle = scopedBinding.BindingScope.FeatureTitle, ScenarioTitle = scopedBinding.BindingScope.ScenarioTitle }; @@ -189,7 +187,7 @@ public override void RegisterGlobalContainerDefaults(ObjectContainer container) base.RegisterGlobalContainerDefaults(container); container.RegisterTypeAs(); container.RegisterTypeAs(); - container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser(new TagExpressionParser())); + var _ = container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser(new TagExpressionParser())).InstancePerDependency; } } From 20b0f081669e390b5757b766fac27645f6ec3891 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Sun, 9 Nov 2025 10:05:18 -0600 Subject: [PATCH 03/19] Draft update to the documentation on the topic of tag expressions --- docs/automation/scoped-bindings.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/automation/scoped-bindings.md b/docs/automation/scoped-bindings.md index 8401c1025..613c4b8ec 100644 --- a/docs/automation/scoped-bindings.md +++ b/docs/automation/scoped-bindings.md @@ -6,7 +6,7 @@ In some cases however, it is necessary to restrict when step definitions or hook You can restrict the execution of scoped bindings by: -- tag +- tag expression - feature (using the feature title) - scenario (using the scenario title) @@ -28,6 +28,23 @@ Use the `[Scope]` attribute to define the scope: [Scope(Tag = "mytag", Feature = "feature title", Scenario = "scenario title")] ``` +## Tag expressions +A tag expression is an infix boolean expression. Below are some examples: + +|Expression|Description| +|----------|-----------| +|@fast| Scenarios tagged with @fast| +|@wip and not @slow| Scenarios tagged with @wip that aren't also tagged with @slow| +|@smoke and @fast| Scenarios tagged with both @smoke and @fast| +|@gui or @database| Scenarios tagged with either @gui or @database| + +```{note} +The '@' prefix is optional within a tag expression. +``` +For even more advanced tag expressions you can use parenthesis for clarity, or to change operator precedence: + + (@smoke or @ui) and (not @slow) + ## Scoping Rules Scope can be defined at the method or class level. @@ -47,8 +64,7 @@ The following example combines the tag scopes with "OR": ```{code-block} csharp :caption: Step Definition File -[Scope(Tag = "thisTag")] [Scope(Tag = "OrThisTag")] -[Scope(Tag = "thisTag"), Scope(Tag = "OrThisTag")] +[Scope(Tag = "thisTag or thatTag", Feature = "thisFeature")] [Scope(Tag = "OrThisTag", Feature="thatFeature")] ``` ````{note} @@ -103,6 +119,7 @@ public void PerformSimpleSearch(string title) } ``` + ## Scoping Tips & Tricks The following example shows a way to "ignore" executing the scenarios marked with `@manual`. However Reqnroll's tracing will still display the steps, so you can work through the manual scenarios by following the steps in the report. @@ -129,6 +146,11 @@ public class ManualSteps } ``` +```{note} +Can this next section be deleted completely, given that tag expressions can now perform what the example attempts to show? +If we wish to retain this section, we need a more compelling example. +``` + ## Beyond Scope You can define more complex filters using the [`ScenarioContext`](scenario-context.md) class. The following example starts selenium if the scenario is tagged with `@web` _and_ `@automated`. From 71e6fcc34de716659afa64f9b99e830d22a7f7cc Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:19:09 -0600 Subject: [PATCH 04/19] File scope namespace for the ReqnrollTagExpressionParser. --- .../Discovery/ReqnrollTagExpressionParser.cs | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs index 95da8a2a8..0e09b2438 100644 --- a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs @@ -3,34 +3,33 @@ using System.Collections.Generic; using System.Text; -namespace Reqnroll.Bindings.Discovery +namespace Reqnroll.Bindings.Discovery; + +public class ReqnrollTagExpressionParser(ITagExpressionParser tagExpressionParser) : ITagExpressionParser { - public class ReqnrollTagExpressionParser(ITagExpressionParser tagExpressionParser) : ITagExpressionParser + public ITagExpression Parse(string tagExpression) { - public ITagExpression Parse(string tagExpression) - { - return Rewrite(tagExpressionParser.Parse(tagExpression)); - } + return Rewrite(tagExpressionParser.Parse(tagExpression)); + } - private ITagExpression Rewrite(ITagExpression expression) + private ITagExpression Rewrite(ITagExpression expression) + { + return expression switch { - return expression switch - { - NullExpression nullExpression => nullExpression, - NotNode notNode => new NotNode(Rewrite(notNode.Operand)), - BinaryOpNode binaryOpNode => new BinaryOpNode(binaryOpNode.Op, Rewrite(binaryOpNode.Left), Rewrite(binaryOpNode.Right)), - LiteralNode literalNode => new LiteralNode(PrefixLiteral(literalNode.Name)), - _ => throw new NotSupportedException($"Unsupported tag expression type: {expression.GetType().FullName}"), - }; - } + NullExpression nullExpression => nullExpression, + NotNode notNode => new NotNode(Rewrite(notNode.Operand)), + BinaryOpNode binaryOpNode => new BinaryOpNode(binaryOpNode.Op, Rewrite(binaryOpNode.Left), Rewrite(binaryOpNode.Right)), + LiteralNode literalNode => new LiteralNode(PrefixLiteral(literalNode.Name)), + _ => throw new NotSupportedException($"Unsupported tag expression type: {expression.GetType().FullName}"), + }; + } - private string PrefixLiteral(string name) - { - if (name.IsNullOrEmpty() ) - return name; - if (name.StartsWith("@")) - return name; - return "@" + name; - } + private string PrefixLiteral(string name) + { + if (name.IsNullOrEmpty() ) + return name; + if (name.StartsWith("@")) + return name; + return "@" + name; } } From 7033a85dafe63635124c6985c94837b5bc579686 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Wed, 26 Nov 2025 10:05:45 -0600 Subject: [PATCH 05/19] Added nuget package reference to Cucumber.TagExpressions; dropping the direct assembly reference. --- Reqnroll/Reqnroll.csproj | 17 ++++------------- .../Reqnroll.RuntimeTests.csproj | 6 ------ 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/Reqnroll/Reqnroll.csproj b/Reqnroll/Reqnroll.csproj index 65bfba3c5..4127590c7 100644 --- a/Reqnroll/Reqnroll.csproj +++ b/Reqnroll/Reqnroll.csproj @@ -18,6 +18,7 @@ + @@ -38,9 +39,7 @@ - + @@ -69,11 +68,7 @@ - + @@ -81,11 +76,7 @@ - + diff --git a/Tests/Reqnroll.RuntimeTests/Reqnroll.RuntimeTests.csproj b/Tests/Reqnroll.RuntimeTests/Reqnroll.RuntimeTests.csproj index 29491b16c..fab9101ac 100644 --- a/Tests/Reqnroll.RuntimeTests/Reqnroll.RuntimeTests.csproj +++ b/Tests/Reqnroll.RuntimeTests/Reqnroll.RuntimeTests.csproj @@ -37,12 +37,6 @@ - - - ..\..\..\cucumber\tag-expressions\dotnet\TagExpressions\bin\Debug\netstandard2.0\Cucumber.TagExpressions.dll - - - From d6cbd0a24ea05942b8dbbfce12b72978b81133e4 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:39:19 -0600 Subject: [PATCH 06/19] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfba26df..ce9e34c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Formatters: configured OutputFilePath may now contain variable substitution parameters for build metadata, timestamp, and environment variables. (#930) * Improved packaging of Reqnroll NuGet packages (#914) +* Tag Expressions: Step Definition Scopes and Hook Bindings may now use tag expressions (such as [Scope("@db and not @slow)]) (#911) ## Bug fixes: From 259e77bfc886b308d8f8af061924c20442f3857c Mon Sep 17 00:00:00 2001 From: Julian Verdurmen <5808377+304NotModified@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:59:51 +0100 Subject: [PATCH 07/19] Update CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c1898e5..c13d599ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ * Support for linked feature files (files used from outside of the project folder). To use this feature, the `ReqnrollUseIntermediateOutputPathForCodeBehind` flag must be enabled (see above). (#948) * Updated TUnit integration to support TUnit v1.3.25 and .NET 10 SDK compatibility (#918) - ## Bug fixes: * Fix: Error during build "System.TypeLoadException: Method 'DisposeAsync' in type 'System.Text.Json.Utf8JsonWriter" (partial fix for some occurrences) (#921, #914) From 8e445108c0179e498aba85f108e58ff528254da8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1sp=C3=A1r=20Nagy?= Date: Wed, 17 Dec 2025 12:12:31 +0100 Subject: [PATCH 08/19] code cleanup --- CHANGELOG.md | 2 +- Reqnroll.Parser/Reqnroll.Parser.csproj | 4 - Reqnroll/Bindings/BindingScope.cs | 25 +- .../Discovery/ReqnrollTagExpressionParser.cs | 2 - .../Provider/BindingProviderService.cs | 1 - .../DefaultDependencyProvider.cs | 2 - Reqnroll/Reqnroll.csproj | 16 +- .../CucumberMessagesValidator.cs | 32 +- .../CucumberExpressionIntegrationTests.cs | 14 +- .../StepDefinitionMatchServiceTest.cs | 350 +++++++++--------- docs/automation/scoped-bindings.md | 5 +- 11 files changed, 223 insertions(+), 230 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c13d599ba..6284c68b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * Formatters: configured OutputFilePath may now contain variable substitution parameters for build metadata, timestamp, and environment variables. (#930) * Improved packaging of Reqnroll NuGet packages (#914) -* Tag Expressions: Step Definition Scopes and Hook Bindings may now use tag expressions (such as [Scope("@db and not @slow)]) (#911) +* Tag Expressions: step definition scopes and hooks may now use tag expressions (such as `@db and not @slow`) (#911) * Improved up-to-date checking for feature files that results in faster builds. As part of this the code-behind files are deleted on clean or rebuild. (#941) * Support for storing the code-behind files in the intermediate output folder (obj folder) by setting the `ReqnrollUseIntermediateOutputPathForCodeBehind` MSBuild property to `true`. (#947) * Support for linked feature files (files used from outside of the project folder). To use this feature, the `ReqnrollUseIntermediateOutputPathForCodeBehind` flag must be enabled (see above). (#948) diff --git a/Reqnroll.Parser/Reqnroll.Parser.csproj b/Reqnroll.Parser/Reqnroll.Parser.csproj index 207d5ca07..83caa23b9 100644 --- a/Reqnroll.Parser/Reqnroll.Parser.csproj +++ b/Reqnroll.Parser/Reqnroll.Parser.csproj @@ -18,8 +18,4 @@ - - - - diff --git a/Reqnroll/Bindings/BindingScope.cs b/Reqnroll/Bindings/BindingScope.cs index 9d029a57f..11e62ac22 100644 --- a/Reqnroll/Bindings/BindingScope.cs +++ b/Reqnroll/Bindings/BindingScope.cs @@ -4,28 +4,23 @@ namespace Reqnroll.Bindings { - public class BindingScope + public class BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle) { - public string Tag { get => _tagExpression.ToString(); } - public string FeatureTitle { get; private set; } - public string ScenarioTitle { get; private set; } - private readonly ITagExpression _tagExpression; - public BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle) - { - _tagExpression = tagExpression; - FeatureTitle = featureTitle; - ScenarioTitle = scenarioTitle; - } + public string Tag => tagExpression.ToString(); + + public string FeatureTitle { get; } = featureTitle; + + public string ScenarioTitle { get; } = scenarioTitle; public bool Match(StepContext stepContext, out int scopeMatches) { scopeMatches = 0; - var tags = stepContext.Tags.Select(t => "@" + t).ToList(); - if (Tag != null) { - if (!_tagExpression.Evaluate(tags)) + var tags = stepContext.Tags.Select(t => "@" + t).ToList(); + + if (!tagExpression.Evaluate(tags)) return false; scopeMatches++; @@ -57,7 +52,7 @@ public override bool Equals(object obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; - if (obj.GetType() != this.GetType()) return false; + if (obj.GetType() != GetType()) return false; return Equals((BindingScope) obj); } diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs index 0e09b2438..d736daaf5 100644 --- a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs @@ -1,7 +1,5 @@ using Cucumber.TagExpressions; using System; -using System.Collections.Generic; -using System.Text; namespace Reqnroll.Bindings.Discovery; diff --git a/Reqnroll/Bindings/Provider/BindingProviderService.cs b/Reqnroll/Bindings/Provider/BindingProviderService.cs index 4b5d8bef0..e9c5123cd 100644 --- a/Reqnroll/Bindings/Provider/BindingProviderService.cs +++ b/Reqnroll/Bindings/Provider/BindingProviderService.cs @@ -3,7 +3,6 @@ using Reqnroll.Bindings.Provider.Data; using Reqnroll.Bindings.Reflection; using Reqnroll.BoDi; -using Reqnroll.CommonModels; using Reqnroll.Configuration; using Reqnroll.EnvironmentAccess; using Reqnroll.Formatters.Configuration; diff --git a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs index 6bf8b5ebf..bbe59c09a 100644 --- a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs +++ b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs @@ -28,8 +28,6 @@ using Reqnroll.Time; using Reqnroll.Tracing; using Reqnroll.Utils; -using System; -using System.ComponentModel; namespace Reqnroll.Infrastructure { diff --git a/Reqnroll/Reqnroll.csproj b/Reqnroll/Reqnroll.csproj index 4127590c7..2ce92c6fe 100644 --- a/Reqnroll/Reqnroll.csproj +++ b/Reqnroll/Reqnroll.csproj @@ -39,7 +39,9 @@ - + @@ -68,7 +70,11 @@ - + @@ -76,7 +82,11 @@ - + diff --git a/Tests/Reqnroll.Formatters.Tests/CucumberMessagesValidator.cs b/Tests/Reqnroll.Formatters.Tests/CucumberMessagesValidator.cs index 09de4c01a..83ca9102c 100644 --- a/Tests/Reqnroll.Formatters.Tests/CucumberMessagesValidator.cs +++ b/Tests/Reqnroll.Formatters.Tests/CucumberMessagesValidator.cs @@ -3,9 +3,7 @@ using FluentAssertions.Execution; using Io.Cucumber.Messages.Types; using Reqnroll.Formatters.PayloadProcessing.Cucumber; -using System.Collections.Concurrent; using System.Reflection; -using System.Runtime.Versioning; namespace Reqnroll.Formatters.Tests; @@ -139,10 +137,10 @@ private void CompareMessageType(int partitionNumber) if (!_expectedElementsByType.ContainsKey(typeof(T))) return; - int actualsPartitionNumber = MapPartitionNumber(partitionNumber); + int actualPartitionNumber = MapPartitionNumber(partitionNumber); var actual = _actualElementsByType.TryGetValue(typeof(T), out HashSet? actualElements) && actualElements.Count > 0 ? - actualElements.OfType().Where(e => _actualPartitions[e!] == actualsPartitionNumber).ToList() : new List(); + actualElements.OfType().Where(e => _actualPartitions[e!] == actualPartitionNumber).ToList() : new List(); var expected = _expectedElementsByType[typeof(T)].AsEnumerable().OfType().Where(e => _expectedPartitions[e!] == partitionNumber).ToList(); @@ -191,11 +189,11 @@ private void ActualTestExecutionMessagesShouldReferBackToTheSameStepTextAsExpect for (int i = 0; i < _numPartitions; i++) { var partitionNumber = i + 1; - int actualsPartitionNumber = MapPartitionNumber(partitionNumber); + int actualPartitionNumber = MapPartitionNumber(partitionNumber); // For each TestStepStarted message, ensure that the pickle step referred to is the same in Actual and Expected for the corresponding testStepStarted message - var actualTestStepStartedTestStepIds = _actualElementsByType[typeof(TestStepStarted)].OfType().Where(e => _actualPartitions[e!] == actualsPartitionNumber).Select(tss => tss.TestStepId).ToList(); - var expectedTestStepStartedTestStepIds = _expectedElementsByType[typeof(TestStepStarted)].OfType().Where(e => _expectedPartitions[e!] == partitionNumber).Select(tss => tss.TestStepId).ToList(); + var actualTestStepStartedTestStepIds = _actualElementsByType[typeof(TestStepStarted)].OfType().Where(e => _actualPartitions[e] == actualPartitionNumber).Select(tss => tss.TestStepId).ToList(); + var expectedTestStepStartedTestStepIds = _expectedElementsByType[typeof(TestStepStarted)].OfType().Where(e => _expectedPartitions[e] == partitionNumber).Select(tss => tss.TestStepId).ToList(); // Making the assumption here that the order of TestStepStarted messages is the same in both Actual and Expected within a Partition // pair these up, and walk back to the pickle step text and compare @@ -330,7 +328,7 @@ private void EachTestCaseAndStepsShouldProperlyReferToAPickleAndStepDefinitionOr if (step.PickleStepId != null) _actualElementsById.Should().ContainKey(step.PickleStepId, "a step references a pickle step that doesn't exist"); - if (step.StepDefinitionIds != null && step.StepDefinitionIds.Count > 0) + if (step.StepDefinitionIds is { Count: > 0 }) { foreach (var stepDefinitionId in step.StepDefinitionIds) _actualElementsById.Should().ContainKey(stepDefinitionId, "a step references a step definition that doesn't exist"); @@ -374,19 +372,19 @@ public void ShouldPassBasicStructuralChecks() { throw new System.Exception($"{messageType} present in the expected but not in the actual."); } - if (messageType != typeof(Hook) && _actualElementsByType.ContainsKey(messageType)) + if (messageType != typeof(Hook) && _actualElementsByType.TryGetValue(messageType, out var nonHookElement)) { - _actualElementsByType[messageType].Should().HaveCount(_expectedElementsByType[messageType].Count()); + nonHookElement.Should().HaveCount(_expectedElementsByType[messageType].Count); } - if (messageType == typeof(Hook) && _actualElementsByType.ContainsKey(messageType)) - _actualElementsByType[messageType].Should().HaveCountGreaterThanOrEqualTo(_expectedElementsByType[messageType].Count()); + if (messageType == typeof(Hook) && _actualElementsByType.TryGetValue(messageType, out var hookElement)) + hookElement.Should().HaveCountGreaterThanOrEqualTo(_expectedElementsByType[messageType].Count); } actual.Should().HaveCountGreaterThanOrEqualTo(expected.Count(), "the total number of envelopes in the actual should be at least as many as in the expected"); } } - private bool GroupListIsEmpty(List groups) + private bool GroupListIsEmpty(List? groups) { if (groups == null || groups.Count == 0) return true; foreach (var group in groups) @@ -407,7 +405,7 @@ private EquivalencyAssertionOptions ArrangeFluentAssertionOptions(Equivale .ComparingByMembers() .ComparingByMembers() .ComparingByMembers() - .ComparingByMembers() + .ComparingByMembers() .ComparingByMembers() .ComparingByMembers() .ComparingByMembers() @@ -458,7 +456,7 @@ private EquivalencyAssertionOptions ArrangeFluentAssertionOptions(Equivale // Using a custom Property Selector so that we can ignore the properties that are not comparable .Using(_customCucumberMessagesPropertySelector) - // Using a custom string comparison that deals with ISO langauge codes when the property name ends with "Language" + // Using a custom string comparison that deals with ISO language codes when the property name ends with "Language" //.Using(ctx => //{ // var actual = ctx.Subject.Split("-")[0]; @@ -466,7 +464,7 @@ private EquivalencyAssertionOptions ArrangeFluentAssertionOptions(Equivale // actual.Should().Be(expected); //}) - // Using special logic to assert that suggestions must contain at least one snippets among those specified in the Expected set + // Using special logic to assert that suggestions must contain at least one snippet among those specified in the Expected set // We can't compare snippet content as the Language and Code properties won't match .Using(ctx => { @@ -547,7 +545,7 @@ private EquivalencyAssertionOptions ArrangeFluentAssertionOptions(Equivale actualList.Should().HaveCountGreaterThanOrEqualTo(expectedList.Count, "actual collection should have at least as many items as expected"); - // Difficult to compare individual Hook messages (Ids aren't comparable, the Source references aren't compatible, + // Difficult to compare individual Hook messages: Ids aren't comparable, the Source references aren't compatible, // and After Hook execution ordering is different between Reqnroll and CCK. foreach (var expectedItem in expectedList) { diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs index bc03b3204..802645258 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs @@ -1,17 +1,17 @@ -using Cucumber.TagExpressions; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Reqnroll.BoDi; using FluentAssertions; using Moq; using Reqnroll.Bindings; using Reqnroll.Bindings.Discovery; using Reqnroll.Bindings.Reflection; -using Reqnroll.BoDi; using Reqnroll.Infrastructure; using Reqnroll.UnitTestProvider; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; +using Cucumber.TagExpressions; using Xunit; namespace Reqnroll.RuntimeTests.Bindings.CucumberExpressions; diff --git a/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs b/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs index 5d9945808..065d62d6e 100644 --- a/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs +++ b/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Globalization; using Moq; @@ -8,250 +9,247 @@ using FluentAssertions; using Reqnroll.RuntimeTests.ErrorHandling; using Reqnroll.Bindings.Discovery; -using System; -namespace Reqnroll.RuntimeTests.Infrastructure +namespace Reqnroll.RuntimeTests.Infrastructure; + +public class StepDefinitionMatchServiceTest { - - public class StepDefinitionMatchServiceTest - { - private Mock bindingRegistryMock; - private Mock stepArgumentTypeConverterMock; - private readonly CultureInfo bindingCulture = new CultureInfo("en-US", false); - private List whenStepDefinitions; + private readonly Mock _bindingRegistryMock; + private readonly Mock _stepArgumentTypeConverterMock; + private readonly CultureInfo _bindingCulture = new("en-US", false); + private readonly List _whenStepDefinitions; - public StepDefinitionMatchServiceTest() - { - whenStepDefinitions = new List(); - bindingRegistryMock = new Mock(); - bindingRegistryMock.Setup(r => r.GetConsideredStepDefinitions(StepDefinitionType.When, It.IsAny())) - .Returns(whenStepDefinitions); + public StepDefinitionMatchServiceTest() + { + _whenStepDefinitions = new List(); + _bindingRegistryMock = new Mock(); + _bindingRegistryMock.Setup(r => r.GetConsideredStepDefinitions(StepDefinitionType.When, It.IsAny())) + .Returns(_whenStepDefinitions); - stepArgumentTypeConverterMock = new Mock(); - } + _stepArgumentTypeConverterMock = new Mock(); + } - private StepDefinitionMatchService CreateSUT() - { - return new StepDefinitionMatchService(bindingRegistryMock.Object, stepArgumentTypeConverterMock.Object, new StubErrorProvider(), new MatchArgumentCalculator()); - } + private StepDefinitionMatchService CreateSut() + { + return new StepDefinitionMatchService(_bindingRegistryMock.Object, _stepArgumentTypeConverterMock.Object, new StubErrorProvider(), new MatchArgumentCalculator()); + } - private static BindingMethod CreateBindingMethod(string name = "dummy") - { - return new BindingMethod(new BindingType("dummy", "dummy", "dummy"), name, new IBindingParameter[0], null); - } + private static BindingMethod CreateBindingMethod(string name = "dummy") + { + return new BindingMethod(new BindingType("dummy", "dummy", "dummy"), name, Array.Empty(), null); + } - private static BindingMethod CreateBindingMethodWithStringParam(string name = "dummy") - { - return new BindingMethod(new BindingType("dummy", "dummy", "dummy"), name, new IBindingParameter[] { new BindingParameter(new RuntimeBindingType(typeof(string)), "param1") }, null); - } + private static BindingMethod CreateBindingMethodWithStringParam(string name = "dummy") + { + return new BindingMethod(new BindingType("dummy", "dummy", "dummy"), name, [new BindingParameter(new RuntimeBindingType(typeof(string)), "param1")], null); + } - private static BindingMethod CreateBindingMethodWithDataTableParam(string name = "dummy") - { - return new BindingMethod(new BindingType("dummy", "dummy", "dummy"), name, new IBindingParameter[] { new BindingParameter(new RuntimeBindingType(typeof(Table)), "param1") }, null); - } + private static BindingMethod CreateBindingMethodWithDataTableParam(string name = "dummy") + { + return new BindingMethod(new BindingType("dummy", "dummy", "dummy"), name, [new BindingParameter(new RuntimeBindingType(typeof(Table)), "param1")], null); + } - private static BindingMethod CreateBindingMethodWithObjectParam(string name = "dummy") - { - return new BindingMethod(new BindingType("dummy", "dummy", "dummy"), name, new IBindingParameter[] { new BindingParameter(new RuntimeBindingType(typeof(object)), "param1") }, null); - } + private static BindingMethod CreateBindingMethodWithObjectParam(string name = "dummy") + { + return new BindingMethod(new BindingType("dummy", "dummy", "dummy"), name, [new BindingParameter(new RuntimeBindingType(typeof(object)), "param1")], null); + } - private StepInstance CreateSimpleWhen(string text = "I do something", IEnumerable tags = null) - { - tags = tags ?? new string[0]; - var result = new StepInstance(StepDefinitionType.When, StepDefinitionKeyword.When, "When ", text, null, null, - new StepContext("MyFeature", "MyScenario", tags, new CultureInfo("en-US", false))); - return result; - } + private StepInstance CreateSimpleWhen(string text = "I do something", IEnumerable tags = null) + { + tags ??= Array.Empty(); + var result = new StepInstance(StepDefinitionType.When, StepDefinitionKeyword.When, "When ", text, null, null, + new StepContext("MyFeature", "MyScenario", tags, new CultureInfo("en-US", false))); + return result; + } - private BindingScope CreateBindingScope(string tag, string featureTitle, string scenarioTitle) - { - var tagExpressionParser = new ReqnrollTagExpressionParser(new Cucumber.TagExpressions.TagExpressionParser()); - return new BindingScope(tagExpressionParser.Parse(tag), featureTitle, scenarioTitle); - } + private BindingScope CreateBindingScope(string tag, string featureTitle, string scenarioTitle) + { + var tagExpressionParser = new ReqnrollTagExpressionParser(new Cucumber.TagExpressions.TagExpressionParser()); + return new BindingScope(tagExpressionParser.Parse(tag), featureTitle, scenarioTitle); + } - [Fact] - public void Should_GetBestMatch_succeed_when_proper_match() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod())); + [Fact] + public void Should_GetBestMatch_succeed_when_proper_match() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod())); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out _, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out _, out _); - result.Success.Should().BeTrue(); - } + result.Success.Should().BeTrue(); + } - [Fact] - public void Should_GetBestMatch_succeed_when_proper_match_and_non_matching_scopes() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m1"))); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m2"), CreateBindingScope("non-matching-tag", null, null))); + [Fact] + public void Should_GetBestMatch_succeed_when_proper_match_and_non_matching_scopes() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m1"))); + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m2"), CreateBindingScope("non-matching-tag", null, null))); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out _, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out _, out _); - result.Success.Should().BeTrue(); - } + result.Success.Should().BeTrue(); + } - [Fact] - public void Should_GetBestMatch_succeed_when_proper_match_and_matching_tag_expression() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m1"))); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m2"), CreateBindingScope("@alpha and @beta", null, null))); + [Fact] + public void Should_GetBestMatch_succeed_when_proper_match_and_matching_tag_expression() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m1"))); + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("m2"), CreateBindingScope("@alpha and @beta", null, null))); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen("I do something", new string[] { "alpha", "beta" }), bindingCulture, out _, out _); + var result = sut.GetBestMatch(CreateSimpleWhen("I do something", ["alpha", "beta"]), _bindingCulture, out _, out _); - result.Success.Should().BeTrue(); - result.StepBinding.Method.Name.Should().Be("m2"); - } + result.Success.Should().BeTrue(); + result.StepBinding.Method.Name.Should().Be("m2"); + } - [Fact] - public void Should_GetBestMatch_succeed_when_proper_match_with_parameters() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethodWithStringParam())); + [Fact] + public void Should_GetBestMatch_succeed_when_proper_match_with_parameters() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethodWithStringParam())); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out _, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out _, out _); - result.Success.Should().BeTrue(); - } + result.Success.Should().BeTrue(); + } - [Fact] - public void Should_GetBestMatch_succeed_when_proper_match_with_parameters_even_if_there_is_a_DataTable_overload() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethodWithStringParam())); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethodWithDataTableParam())); + [Fact] + public void Should_GetBestMatch_succeed_when_proper_match_with_parameters_even_if_there_is_a_DataTable_overload() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethodWithStringParam())); + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethodWithDataTableParam())); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out _, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out _, out _); - result.Success.Should().BeTrue(); - } + result.Success.Should().BeTrue(); + } - [Fact] - public void Should_GetBestMatch_succeed_when_proper_match_with_object_parameters() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethodWithObjectParam())); + [Fact] + public void Should_GetBestMatch_succeed_when_proper_match_with_object_parameters() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethodWithObjectParam())); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out _, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out _, out _); - result.Success.Should().BeTrue(); - } + result.Success.Should().BeTrue(); + } - [Fact] - public void Should_GetBestMatch_succeed_when_proper_match_with_object_parameters_even_if_there_is_a_DataTable_overload() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethodWithObjectParam())); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethodWithDataTableParam())); + [Fact] + public void Should_GetBestMatch_succeed_when_proper_match_with_object_parameters_even_if_there_is_a_DataTable_overload() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethodWithObjectParam())); + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethodWithDataTableParam())); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out _, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out _, out _); - result.Success.Should().BeTrue(); - } + result.Success.Should().BeTrue(); + } - [Fact] - public void Should_GetBestMatch_fail_when_scope_errors_with_single_match() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod(), CreateBindingScope("non-matching-tag", null, null))); + [Fact] + public void Should_GetBestMatch_fail_when_scope_errors_with_single_match() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod(), CreateBindingScope("non-matching-tag", null, null))); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out var ambiguityReason, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out var ambiguityReason, out _); - result.Success.Should().BeFalse(); - ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.AmbiguousScopes); - } + result.Success.Should().BeFalse(); + ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.AmbiguousScopes); + } - [Fact] - public void Should_GetBestMatch_fail_when_scope_errors_with_multiple_matches() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy1"), CreateBindingScope("non-matching-tag", null, null))); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy2"), CreateBindingScope("other-non-matching-tag", null, null))); + [Fact] + public void Should_GetBestMatch_fail_when_scope_errors_with_multiple_matches() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy1"), CreateBindingScope("non-matching-tag", null, null))); + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy2"), CreateBindingScope("other-non-matching-tag", null, null))); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out var ambiguityReason, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out var ambiguityReason, out _); - result.Success.Should().BeFalse(); - ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.AmbiguousScopes); - } + result.Success.Should().BeFalse(); + ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.AmbiguousScopes); + } - [Fact] // in case of single parameter error, we pretend success - the error will be displayed runtime - public void Should_GetBestMatch_succeed_when_parameter_errors_with_single_match() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethod())); + [Fact] // in case of single parameter error, we pretend success - the error will be displayed runtime + public void Should_GetBestMatch_succeed_when_parameter_errors_with_single_match() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethod())); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out _, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out _, out _); - result.Success.Should().BeTrue(); - } + result.Success.Should().BeTrue(); + } - [Fact] - public void Should_GetBestMatch_fail_when_parameter_errors_with_multiple_matches() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethod("dummy1"))); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethod("dummy2"))); + [Fact] + public void Should_GetBestMatch_fail_when_parameter_errors_with_multiple_matches() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethod("dummy1"))); + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "(.*)", CreateBindingMethod("dummy2"))); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out var ambiguityReason, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out var ambiguityReason, out _); - result.Success.Should().BeFalse(); - ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.ParameterErrors); - } + result.Success.Should().BeFalse(); + ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.ParameterErrors); + } - [Fact] - public void Should_GetBestMatch_fail_when_multiple_matches() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy1"))); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy2"))); + [Fact] + public void Should_GetBestMatch_fail_when_multiple_matches() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy1"))); + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod("dummy2"))); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out var ambiguityReason, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out var ambiguityReason, out _); - result.Success.Should().BeFalse(); - ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.AmbiguousSteps); - } + result.Success.Should().BeFalse(); + ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.AmbiguousSteps); + } - [Fact] - public void Should_GetBestMatch_succeed_when_multiple_matches_are_on_the_same_method() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod())); - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod())); + [Fact] + public void Should_GetBestMatch_succeed_when_multiple_matches_are_on_the_same_method() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod())); + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, ".*", CreateBindingMethod())); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out _, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out _, out _); - result.Success.Should().BeTrue(); - } + result.Success.Should().BeTrue(); + } - [Fact] - public void Should_GetBestMatch_succeed_when_no_matching_step_definitions() - { - whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "non-maching-regex", CreateBindingMethod())); + [Fact] + public void Should_GetBestMatch_succeed_when_no_matching_step_definitions() + { + _whenStepDefinitions.Add(StepDefinitionHelper.CreateRegex(StepDefinitionType.When, "non-matching-regex", CreateBindingMethod())); - var sut = CreateSUT(); + var sut = CreateSut(); - var result = sut.GetBestMatch(CreateSimpleWhen(), bindingCulture, out var ambiguityReason, out _); + var result = sut.GetBestMatch(CreateSimpleWhen(), _bindingCulture, out var ambiguityReason, out _); - result.Success.Should().BeFalse(); - ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.None); - } + result.Success.Should().BeFalse(); + ambiguityReason.Should().Be(StepDefinitionAmbiguityReason.None); } -} +} \ No newline at end of file diff --git a/docs/automation/scoped-bindings.md b/docs/automation/scoped-bindings.md index 613c4b8ec..64f978902 100644 --- a/docs/automation/scoped-bindings.md +++ b/docs/automation/scoped-bindings.md @@ -60,11 +60,12 @@ The following example combines the feature name and the tag scope with "AND": If multiple `[Scope]` attributes are defined for the same method or class, the attributes are combined with OR, i.e. at least one of the `[Scope]` attributes needs to match. -The following example combines the tag scopes with "OR": +The following example combines the scopes with "OR": ```{code-block} csharp :caption: Step Definition File -[Scope(Tag = "thisTag or thatTag", Feature = "thisFeature")] [Scope(Tag = "OrThisTag", Feature="thatFeature")] +[Scope(Tag = "@thisTag or @thatTag", Feature = "thisFeature")] +[Scope(Tag = "@anotherTag", Feature="thatFeature")] ``` ````{note} From d827d4d847b03d7ec584504cc8d2252cd9bb9e02 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:03:31 -0600 Subject: [PATCH 09/19] Changes per review comments: Enforced use of '@' prefix on all tag expressions except single-term (presumably) legacy expressions. Encapsulated Cucumber.TagExpressionParser inside a Reqnroll.TagExpressionParser to control lifetime of the parser object. --- Reqnroll/Bindings/BindingScope.cs | 2 +- .../Discovery/BindingSourceProcessor.cs | 4 +- .../Discovery/IReqnrollTagExpressionParser.cs | 17 +++ .../Discovery/ReqnrollTagExpressionParser.cs | 47 +++++-- .../RuntimeBindingSourceProcessor.cs | 2 +- .../Provider/BindingProviderService.cs | 1 - .../DefaultDependencyProvider.cs | 3 +- .../BindingSourceProcessorStub.cs | 2 +- .../Bindings/BindingFactoryTests.cs | 2 +- .../CucumberExpressionIntegrationTests.cs | 2 +- .../ReqnrollTagExpressionParserTests.cs | 125 ++++++++++++++++++ .../StepDefinitionMatchServiceTest.cs | 2 +- 12 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 Reqnroll/Bindings/Discovery/IReqnrollTagExpressionParser.cs create mode 100644 Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs diff --git a/Reqnroll/Bindings/BindingScope.cs b/Reqnroll/Bindings/BindingScope.cs index 11e62ac22..67fa841fa 100644 --- a/Reqnroll/Bindings/BindingScope.cs +++ b/Reqnroll/Bindings/BindingScope.cs @@ -16,7 +16,7 @@ public bool Match(StepContext stepContext, out int scopeMatches) { scopeMatches = 0; - if (Tag != null) + if (tagExpression is not NullExpression) { var tags = stepContext.Tags.Select(t => "@" + t).ToList(); diff --git a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs index 4c4a53816..3920248c4 100644 --- a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs +++ b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs @@ -10,13 +10,13 @@ namespace Reqnroll.Bindings.Discovery public abstract class BindingSourceProcessor : IBindingSourceProcessor { private readonly IBindingFactory _bindingFactory; - private readonly ITagExpressionParser _tagExpressionParser; + private readonly IReqnrollTagExpressionParser _tagExpressionParser; private BindingSourceType _currentBindingSourceType = null; private BindingScope[] _typeScopes = null; private readonly List _stepDefinitionBindingBuilders = new(); - protected BindingSourceProcessor(IBindingFactory bindingFactory, ITagExpressionParser tagExpressionParser) + protected BindingSourceProcessor(IBindingFactory bindingFactory, IReqnrollTagExpressionParser tagExpressionParser) { _bindingFactory = bindingFactory; _tagExpressionParser = tagExpressionParser; diff --git a/Reqnroll/Bindings/Discovery/IReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/IReqnrollTagExpressionParser.cs new file mode 100644 index 000000000..e0ab2f1c0 --- /dev/null +++ b/Reqnroll/Bindings/Discovery/IReqnrollTagExpressionParser.cs @@ -0,0 +1,17 @@ +using Cucumber.TagExpressions; + +namespace Reqnroll.Bindings.Discovery; + +/// +/// Defines a parser for tag expressions. +/// + +public interface IReqnrollTagExpressionParser +{ + /// + /// Parses the specified text into an . + /// + /// The tag expression string to parse. + /// An representing the parsed expression. + ITagExpression Parse(string text); +} \ No newline at end of file diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs index d736daaf5..69fc57a72 100644 --- a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs @@ -1,33 +1,52 @@ using Cucumber.TagExpressions; using System; +using System.Xml.Linq; namespace Reqnroll.Bindings.Discovery; -public class ReqnrollTagExpressionParser(ITagExpressionParser tagExpressionParser) : ITagExpressionParser +public class ReqnrollTagExpressionParser() : IReqnrollTagExpressionParser { public ITagExpression Parse(string tagExpression) { + var tagExpressionParser = new TagExpressionParser(); return Rewrite(tagExpressionParser.Parse(tagExpression)); } + // iff the expression is a literal node, prefix it with '@' if not already present private ITagExpression Rewrite(ITagExpression expression) { - return expression switch + if (expression is LiteralNode) { - NullExpression nullExpression => nullExpression, - NotNode notNode => new NotNode(Rewrite(notNode.Operand)), - BinaryOpNode binaryOpNode => new BinaryOpNode(binaryOpNode.Op, Rewrite(binaryOpNode.Left), Rewrite(binaryOpNode.Right)), - LiteralNode literalNode => new LiteralNode(PrefixLiteral(literalNode.Name)), - _ => throw new NotSupportedException($"Unsupported tag expression type: {expression.GetType().FullName}"), - }; + return PrefixLiteralNode(expression); + } + if (ConfirmExpressionHasAtPrefixes(expression)) + return expression; + throw new TagExpressionException("In multi-term tag expressions, all tag names must start with '@'."); } - private string PrefixLiteral(string name) + private bool ConfirmExpressionHasAtPrefixes(ITagExpression expression) { - if (name.IsNullOrEmpty() ) - return name; - if (name.StartsWith("@")) - return name; - return "@" + name; + switch (expression) + { + case NullExpression nullNode: + return true; + case BinaryOpNode binaryNode: + return ConfirmExpressionHasAtPrefixes(binaryNode.Left) && ConfirmExpressionHasAtPrefixes(binaryNode.Right); + case NotNode notNode: + return ConfirmExpressionHasAtPrefixes(notNode.Operand); + case LiteralNode literalNode: + return (literalNode.Name.StartsWith("@")); + default: + throw new InvalidOperationException($"Unknown tag expression node type: {expression.GetType().FullName}"); + } + } + + private ITagExpression PrefixLiteralNode(ITagExpression expression) + { + var literalNode = (LiteralNode)expression; + if (literalNode.Name.IsNullOrEmpty() || literalNode.Name.StartsWith("@")) + return literalNode; + + return new LiteralNode("@" + literalNode.Name); } } diff --git a/Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs b/Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs index 82f93afc4..e2fea6db6 100644 --- a/Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs +++ b/Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs @@ -13,7 +13,7 @@ public class RuntimeBindingSourceProcessor : BindingSourceProcessor, IRuntimeBin private readonly IBindingRegistry _bindingRegistry; private readonly ITestTracer _testTracer; - public RuntimeBindingSourceProcessor(IBindingFactory bindingFactory, IBindingRegistry bindingRegistry, ITestTracer testTracer, ITagExpressionParser tagExpressionParser) : base(bindingFactory, tagExpressionParser) + public RuntimeBindingSourceProcessor(IBindingFactory bindingFactory, IBindingRegistry bindingRegistry, ITestTracer testTracer, IReqnrollTagExpressionParser tagExpressionParser) : base(bindingFactory, tagExpressionParser) { _bindingRegistry = bindingRegistry; _testTracer = testTracer; diff --git a/Reqnroll/Bindings/Provider/BindingProviderService.cs b/Reqnroll/Bindings/Provider/BindingProviderService.cs index e9c5123cd..1815d6cd7 100644 --- a/Reqnroll/Bindings/Provider/BindingProviderService.cs +++ b/Reqnroll/Bindings/Provider/BindingProviderService.cs @@ -186,7 +186,6 @@ public override void RegisterGlobalContainerDefaults(ObjectContainer container) base.RegisterGlobalContainerDefaults(container); container.RegisterTypeAs(); container.RegisterTypeAs(); - var _ = container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser(new TagExpressionParser())).InstancePerDependency; } } diff --git a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs index bbe59c09a..f4f64ee93 100644 --- a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs +++ b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs @@ -135,8 +135,7 @@ public virtual void RegisterGlobalContainerDefaults(ObjectContainer container) container.RegisterTypeAs(); container.RegisterFactoryAs(() => container.Resolve()); container.RegisterTypeAs(); - - var _ = container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser(new TagExpressionParser())).InstancePerDependency; + container.RegisterTypeAs(); } public virtual void RegisterTestThreadContainerDefaults(ObjectContainer testThreadContainer) diff --git a/Tests/Reqnroll.RuntimeTests/BindingSourceProcessorStub.cs b/Tests/Reqnroll.RuntimeTests/BindingSourceProcessorStub.cs index c7beb6e16..c0db55ffe 100644 --- a/Tests/Reqnroll.RuntimeTests/BindingSourceProcessorStub.cs +++ b/Tests/Reqnroll.RuntimeTests/BindingSourceProcessorStub.cs @@ -18,7 +18,7 @@ public class BindingSourceProcessorStub : BindingSourceProcessor, IRuntimeBindin public IEnumerable ValidationErrors => GeneralErrorMessages.Concat(BindingSpecificErrorMessages); - public BindingSourceProcessorStub() : base(new BindingFactory(new StepDefinitionRegexCalculator(ConfigurationLoader.GetDefault()), new CucumberExpressionStepDefinitionBindingBuilderFactory(new CucumberExpressionParameterTypeRegistry(new BindingRegistry())), new CucumberExpressionDetector()), new ReqnrollTagExpressionParser(new TagExpressionParser())) + public BindingSourceProcessorStub() : base(new BindingFactory(new StepDefinitionRegexCalculator(ConfigurationLoader.GetDefault()), new CucumberExpressionStepDefinitionBindingBuilderFactory(new CucumberExpressionParameterTypeRegistry(new BindingRegistry())), new CucumberExpressionDetector()), new ReqnrollTagExpressionParser()) { } diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/BindingFactoryTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/BindingFactoryTests.cs index d9698ffc7..ba50694e0 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/BindingFactoryTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/BindingFactoryTests.cs @@ -24,7 +24,7 @@ public void CreateStepDefinitionBindingBuilderTest(ExpressionType expressionType var cucumberExpressionStepDefinitionBindingBuilderFactory = new CucumberExpressionStepDefinitionBindingBuilderFactory(new CucumberExpressionParameterTypeRegistry(Mock.Of())); var cucumberExpressionDetector = new CucumberExpressionDetector(); var sut = new BindingFactory(stepDefinitionRegexCalculator.Object, cucumberExpressionStepDefinitionBindingBuilderFactory, cucumberExpressionDetector); - var tagExpressionParser = new ReqnrollTagExpressionParser(new Cucumber.TagExpressions.TagExpressionParser()); + var tagExpressionParser = new ReqnrollTagExpressionParser(); var stepDefinitionType = StepDefinitionType.Given; var bindingMethod = new Mock().Object; var bindingScope = new BindingScope(tagExpressionParser.Parse("tag1"), "feature1", "scenario1"); diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs index 802645258..48b80b4bc 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs @@ -153,7 +153,7 @@ public override void RegisterGlobalContainerDefaults(ObjectContainer container) base.RegisterGlobalContainerDefaults(container); var stubUintTestProvider = new Mock(); container.RegisterInstanceAs(stubUintTestProvider.Object, "nunit"); - container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser(new TagExpressionParser())); + container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser()); } } diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs new file mode 100644 index 000000000..04fc34d7e --- /dev/null +++ b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Xunit; +using FluentAssertions; +using Reqnroll.Bindings.Discovery; +using Cucumber.TagExpressions; + +namespace Reqnroll.RuntimeTests.Bindings.Discovery; + +public class ReqnrollTagExpressionParserTests +{ + private ReqnrollTagExpressionParser CreateParser() + { + return new ReqnrollTagExpressionParser(); + } + + [Theory] + [InlineData("tag1")] + [InlineData("myTag")] + [InlineData("feature")] + public void Single_term_expressions_without_at_prefix_are_correctly_prefixed(string tagName) + { + // Arrange + var parser = CreateParser(); + + // Act + var expression = parser.Parse(tagName); + + // Assert + expression.Should().NotBeNull(); + expression.Evaluate(new[] { "@" + tagName }).Should().BeTrue("tag with @ prefix should match"); + expression.Evaluate(new[] { tagName }).Should().BeFalse("tag without @ prefix should not match"); + } + + [Theory] + [InlineData("@tag1")] + [InlineData("@myTag")] + [InlineData("@feature")] + public void Single_term_expressions_with_at_prefix_remain_unchanged(string tagName) + { + // Arrange + var parser = CreateParser(); + + // Act + var expression = parser.Parse(tagName); + + // Assert + expression.Should().NotBeNull(); + expression.Evaluate(new[] { tagName }).Should().BeTrue("tag with @ prefix should match"); + } + + + [Theory] + [InlineData("tag1 and tag2")] + [InlineData("tag1 or tag2")] + [InlineData("not tag1")] + [InlineData("tag1 and @tag2")] + [InlineData("@tag1 or tag2")] + public void Multi_term_expressions_without_at_prefix_throw_exception(string expression) + { + // Arrange + var parser = CreateParser(); + + // Act + Action act = () => parser.Parse(expression); + + // Assert + act.Should().Throw(); + } + + + [Theory] + [InlineData("@tag1 and @tag2", new[] { "@tag1", "@tag2" }, true)] + [InlineData("@tag1 and @tag2", new[] { "@tag1" }, false)] + [InlineData("@tag1 or @tag2", new[] { "@tag1" }, true)] + [InlineData("@tag1 or @tag2", new[] { "@tag2" }, true)] + [InlineData("@tag1 or @tag2", new[] { "@tag3" }, false)] + [InlineData("not @tag1", new[] { "@tag1" }, false)] + [InlineData("not @tag1", new[] { "@tag2" }, true)] + public void Multi_term_expressions_with_at_prefix_work_correctly(string expression, string[] tags, bool expectedResult) + { + // Arrange + var parser = CreateParser(); + + // Act + var parsedExpression = parser.Parse(expression); + + // Assert + parsedExpression.Should().NotBeNull(); + parsedExpression.Evaluate(tags).Should().Be(expectedResult); + } + + + [Fact] + public void Empty_tag_expression_returns_true_for_any_tags() + { + // Arrange + var parser = CreateParser(); + + // Act + var expression = parser.Parse(""); + + // Assert + expression.Should().NotBeNull(); + expression.Evaluate(new[] { "@tag1" }).Should().BeTrue(); + expression.Evaluate(Array.Empty()).Should().BeTrue(); + } + + [Fact] + public void Null_or_empty_tag_names_are_handled_correctly() + { + // Arrange + var parser = CreateParser(); + + // Act & Assert - empty string should be handled + var expression1 = parser.Parse(""); + expression1.Should().NotBeNull(); + + // Null expression should be handled by underlying parser + Action act = () => parser.Parse(null); + act.Should().NotThrow(); + } + +} diff --git a/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs b/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs index 065d62d6e..ef4cffa42 100644 --- a/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs +++ b/Tests/Reqnroll.RuntimeTests/Infrastructure/StepDefinitionMatchServiceTest.cs @@ -64,7 +64,7 @@ private static BindingMethod CreateBindingMethodWithObjectParam(string name = "d private BindingScope CreateBindingScope(string tag, string featureTitle, string scenarioTitle) { - var tagExpressionParser = new ReqnrollTagExpressionParser(new Cucumber.TagExpressions.TagExpressionParser()); + var tagExpressionParser = new ReqnrollTagExpressionParser(); return new BindingScope(tagExpressionParser.Parse(tag), featureTitle, scenarioTitle); } From 98946cf02f760d6bead5c5a959f3858ce1e06e96 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:11:04 -0600 Subject: [PATCH 10/19] Update scoped-bindings.md Update doc to explicitly state that the '@' prefix is required in tag expressions (except for legacy single-term expressions). --- docs/automation/scoped-bindings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/automation/scoped-bindings.md b/docs/automation/scoped-bindings.md index 64f978902..b49ae99e1 100644 --- a/docs/automation/scoped-bindings.md +++ b/docs/automation/scoped-bindings.md @@ -39,7 +39,7 @@ A tag expression is an infix boolean expression. Below are some examples: |@gui or @database| Scenarios tagged with either @gui or @database| ```{note} -The '@' prefix is optional within a tag expression. +The '@' prefix is required within a tag expression, except for single-term expressions (such as "foo"). ``` For even more advanced tag expressions you can use parenthesis for clarity, or to change operator precedence: From 7246af2c91b8b11129d8c8de7c27294772a8b3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1sp=C3=A1r=20Nagy?= Date: Wed, 7 Jan 2026 15:07:57 +0100 Subject: [PATCH 11/19] code cleanup --- .../Bindings/Discovery/IReqnrollTagExpressionParser.cs | 1 - .../Bindings/Discovery/ReqnrollTagExpressionParser.cs | 7 +++---- .../CucumberExpressionIntegrationTests.cs | 2 -- .../Discovery/ReqnrollTagExpressionParserTests.cs | 10 ++++------ 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/Reqnroll/Bindings/Discovery/IReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/IReqnrollTagExpressionParser.cs index e0ab2f1c0..8d62c5dc3 100644 --- a/Reqnroll/Bindings/Discovery/IReqnrollTagExpressionParser.cs +++ b/Reqnroll/Bindings/Discovery/IReqnrollTagExpressionParser.cs @@ -5,7 +5,6 @@ namespace Reqnroll.Bindings.Discovery; /// /// Defines a parser for tag expressions. /// - public interface IReqnrollTagExpressionParser { /// diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs index 69fc57a72..f4afac5b5 100644 --- a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs @@ -1,10 +1,9 @@ using Cucumber.TagExpressions; using System; -using System.Xml.Linq; namespace Reqnroll.Bindings.Discovery; -public class ReqnrollTagExpressionParser() : IReqnrollTagExpressionParser +public class ReqnrollTagExpressionParser : IReqnrollTagExpressionParser { public ITagExpression Parse(string tagExpression) { @@ -28,14 +27,14 @@ private bool ConfirmExpressionHasAtPrefixes(ITagExpression expression) { switch (expression) { - case NullExpression nullNode: + case NullExpression: return true; case BinaryOpNode binaryNode: return ConfirmExpressionHasAtPrefixes(binaryNode.Left) && ConfirmExpressionHasAtPrefixes(binaryNode.Right); case NotNode notNode: return ConfirmExpressionHasAtPrefixes(notNode.Operand); case LiteralNode literalNode: - return (literalNode.Name.StartsWith("@")); + return literalNode.Name.StartsWith("@"); default: throw new InvalidOperationException($"Unknown tag expression node type: {expression.GetType().FullName}"); } diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs index 48b80b4bc..4b77d1970 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/CucumberExpressions/CucumberExpressionIntegrationTests.cs @@ -11,7 +11,6 @@ using Reqnroll.Bindings.Reflection; using Reqnroll.Infrastructure; using Reqnroll.UnitTestProvider; -using Cucumber.TagExpressions; using Xunit; namespace Reqnroll.RuntimeTests.Bindings.CucumberExpressions; @@ -153,7 +152,6 @@ public override void RegisterGlobalContainerDefaults(ObjectContainer container) base.RegisterGlobalContainerDefaults(container); var stubUintTestProvider = new Mock(); container.RegisterInstanceAs(stubUintTestProvider.Object, "nunit"); - container.RegisterFactoryAs(() => new ReqnrollTagExpressionParser()); } } diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs index 04fc34d7e..ed7a10eb1 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.Text; using Xunit; using FluentAssertions; using Reqnroll.Bindings.Discovery; @@ -29,8 +27,8 @@ public void Single_term_expressions_without_at_prefix_are_correctly_prefixed(str // Assert expression.Should().NotBeNull(); - expression.Evaluate(new[] { "@" + tagName }).Should().BeTrue("tag with @ prefix should match"); - expression.Evaluate(new[] { tagName }).Should().BeFalse("tag without @ prefix should not match"); + expression.Evaluate(["@" + tagName]).Should().BeTrue("tag with @ prefix should match"); + expression.Evaluate([tagName]).Should().BeFalse("tag without @ prefix should not match"); } [Theory] @@ -47,7 +45,7 @@ public void Single_term_expressions_with_at_prefix_remain_unchanged(string tagNa // Assert expression.Should().NotBeNull(); - expression.Evaluate(new[] { tagName }).Should().BeTrue("tag with @ prefix should match"); + expression.Evaluate([tagName]).Should().BeTrue("tag with @ prefix should match"); } @@ -103,7 +101,7 @@ public void Empty_tag_expression_returns_true_for_any_tags() // Assert expression.Should().NotBeNull(); - expression.Evaluate(new[] { "@tag1" }).Should().BeTrue(); + expression.Evaluate(["@tag1"]).Should().BeTrue(); expression.Evaluate(Array.Empty()).Should().BeTrue(); } From 80b5986695688e9fd52e1a52f01f49562481e1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1sp=C3=A1r=20Nagy?= Date: Wed, 7 Jan 2026 15:16:34 +0100 Subject: [PATCH 12/19] fix BindingProviderService to work with empty Tag --- Reqnroll/Bindings/Provider/BindingProviderService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Reqnroll/Bindings/Provider/BindingProviderService.cs b/Reqnroll/Bindings/Provider/BindingProviderService.cs index 1815d6cd7..3d8b16af3 100644 --- a/Reqnroll/Bindings/Provider/BindingProviderService.cs +++ b/Reqnroll/Bindings/Provider/BindingProviderService.cs @@ -136,9 +136,10 @@ BindingScopeData GetScope(IScopedBinding scopedBinding) if (!scopedBinding.IsScoped) return null; + string tagScope = scopedBinding.BindingScope.Tag; return new BindingScopeData { - Tag = scopedBinding.BindingScope.Tag, + Tag = string.IsNullOrEmpty(tagScope) ? null : tagScope, FeatureTitle = scopedBinding.BindingScope.FeatureTitle, ScenarioTitle = scopedBinding.BindingScope.ScenarioTitle }; From f7dd13933eb1a3629d1128c5550edc30d7ed71c9 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:25:10 -0600 Subject: [PATCH 13/19] Wrapping tag parsing errors in a new type of ITagExpression which drives the BindingSourceProcessor to add BindingErrors to the BindingResult. --- Reqnroll/Bindings/BindingScope.cs | 1 + .../Discovery/BindingSourceProcessor.cs | 15 ++++++++++++ .../Discovery/InvalidTagExpression.cs | 23 +++++++++++++++++++ .../Discovery/ReqnrollTagExpressionParser.cs | 14 ++++++++++- 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 Reqnroll/Bindings/Discovery/InvalidTagExpression.cs diff --git a/Reqnroll/Bindings/BindingScope.cs b/Reqnroll/Bindings/BindingScope.cs index 67fa841fa..f89e0842a 100644 --- a/Reqnroll/Bindings/BindingScope.cs +++ b/Reqnroll/Bindings/BindingScope.cs @@ -7,6 +7,7 @@ namespace Reqnroll.Bindings public class BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle) { public string Tag => tagExpression.ToString(); + public ITagExpression TagExpression => tagExpression; public string FeatureTitle { get; } = featureTitle; diff --git a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs index 3920248c4..4a27248dd 100644 --- a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs +++ b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs @@ -182,6 +182,8 @@ private void ProcessHookAttribute(BindingSourceMethod bindingSourceMethod, Bindi int order = GetHookOrder(hookAttribute); var validationResult = ValidateHook(bindingSourceMethod, hookAttribute, hookType); + validationResult += ValidateBindingScope(scope); + if (!validationResult.IsValid) { OnValidationError(validationResult, true); @@ -253,6 +255,8 @@ private void ProcessStepDefinitionAttribute(BindingSourceMethod bindingSourceMet var expressionType = stepDefinitionAttribute.TryGetAttributeValue(nameof(StepDefinitionBaseAttribute.ExpressionType)); var validationResult = ValidateStepDefinition(bindingSourceMethod, stepDefinitionAttribute); + validationResult += ValidateBindingScope(scope); + if (!validationResult.IsValid) { OnValidationError(validationResult, false); @@ -355,6 +359,17 @@ protected virtual BindingValidationResult ValidateHook(BindingSourceMethod bindi return result; } + protected virtual BindingValidationResult ValidateBindingScope(BindingScope bindingScope) + { + var result = BindingValidationResult.Valid; + + if (bindingScope != null && bindingScope.TagExpression is InvalidTagExpression invalidTagExpression) + { + result += BindingValidationResult.Error(invalidTagExpression.Message); + } + return result; + } + protected bool IsScenarioSpecificHook(HookType hookType) { return diff --git a/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs b/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs new file mode 100644 index 000000000..be519e518 --- /dev/null +++ b/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs @@ -0,0 +1,23 @@ +using Cucumber.TagExpressions; +using System; + +namespace Reqnroll.Bindings.Discovery; + +public class InvalidTagExpression : ITagExpression +{ + public string Message { get; } + public string OriginalTagExpression { get; } + public InvalidTagExpression(string originalTagExpression, string message) + { + OriginalTagExpression = originalTagExpression; + Message = message; + } + public bool Evaluate(System.Collections.Generic.IEnumerable tags) + { + throw new InvalidOperationException("Cannot evaluate an invalid tag expression: " + Message); + } + public override string ToString() + { + return "Invalid Tag Expression: " + Message; + } +} diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs index f4afac5b5..62a2a73fe 100644 --- a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs @@ -8,7 +8,19 @@ public class ReqnrollTagExpressionParser : IReqnrollTagExpressionParser public ITagExpression Parse(string tagExpression) { var tagExpressionParser = new TagExpressionParser(); - return Rewrite(tagExpressionParser.Parse(tagExpression)); + try { + var parsedExpression = tagExpressionParser.Parse(tagExpression); + return Rewrite(parsedExpression); + } + catch (TagExpressionException ex) + { + var msg = ex.Message; + if (ex.TagToken != null) + { + msg += $" (at offset {ex.TagToken.Position})"; + } + return new InvalidTagExpression(tagExpression, msg); + } } // iff the expression is a literal node, prefix it with '@' if not already present From 5d060d715268c11a6c6573f1eeba8ea4b30059d8 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:13:33 -0600 Subject: [PATCH 14/19] Updated tests. Updated Changelog. Added sample to Formatters tests that demonstrate use of tag expressions on hooks. --- CHANGELOG.md | 3 +- .../MessagesCompatibilityTests.cs | 2 +- .../Reqnroll.Formatters.Tests.csproj | 7 ++-- .../tag-expressions/tag_expressions.cs | 34 +++++++++++++++++++ .../tag-expressions/tag_expressions.feature | 11 ++++++ .../tag-expressions/tag_expressions.ndjson | 30 ++++++++++++++++ .../ReqnrollTagExpressionParserTests.cs | 6 ++-- 7 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.cs create mode 100644 Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.feature create mode 100644 Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.ndjson diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c01fa8a..d303ee7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,12 @@ # [vNext] ## Improvements: +* Tag Expressions: step definition scopes and hooks may now use tag expressions (such as `@db and not @slow`) (#911) ## Bug fixes: *Contributors of this release (in alphabetical order):* +@clrudolphi # v3.3.1 - 2026-01-08 @@ -24,7 +26,6 @@ * NUnit dependencies are updated to v4.4.0 in templates. This does not impact Reqnroll compatibility. (#846) * Formatters: configured OutputFilePath may now contain variable substitution parameters for build metadata, timestamp, and environment variables. See https://docs.reqnroll.net/latest/installation/formatter-configuration.html#available-substitution-variables for details. (#930) * Improved packaging of Reqnroll NuGet packages (#914) -* Tag Expressions: step definition scopes and hooks may now use tag expressions (such as `@db and not @slow`) (#911) * Improved up-to-date checking for feature files that results in faster builds. As part of this the code-behind files are deleted on clean or rebuild. (#941) * Support for storing the code-behind files in the intermediate output folder (obj folder) by setting the `ReqnrollUseIntermediateOutputPathForCodeBehind` MSBuild property to `true`. (#947) * Support for linked feature files (files used from outside of the project folder). To use this feature, the `ReqnrollUseIntermediateOutputPathForCodeBehind` flag must be enabled (see above). (#948) diff --git a/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTests.cs b/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTests.cs index 701afdec4..27312ba21 100644 --- a/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTests.cs +++ b/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTests.cs @@ -80,8 +80,8 @@ public void CCKScenarios(string testName) ConfirmAllTestsRan(numOfTests); } - [Ignore] [TestMethod] + [DataRow("tag-expressions")] // These tests are not (yet) within the CCK but are included here to round out the testing. The expected results were generated by the CucumberMessages plugin. // Once the CCK includes these scenarios, the expected results should come from the CCK repo. public void NonCCKScenarios(string testName) diff --git a/Tests/Reqnroll.Formatters.Tests/Reqnroll.Formatters.Tests.csproj b/Tests/Reqnroll.Formatters.Tests/Reqnroll.Formatters.Tests.csproj index f7624cec5..557fe120b 100644 --- a/Tests/Reqnroll.Formatters.Tests/Reqnroll.Formatters.Tests.csproj +++ b/Tests/Reqnroll.Formatters.Tests/Reqnroll.Formatters.Tests.csproj @@ -32,6 +32,10 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -45,8 +49,7 @@ - + all false diff --git a/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.cs b/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.cs new file mode 100644 index 000000000..5365f33a5 --- /dev/null +++ b/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Reqnroll; + +namespace Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions; + +[Binding] +internal class tag_expressions +{ + [Given("something")] + public void GivenSomething() + { + // No-op + } + + [Given("something else")] + public void GivenSomethingElse() + { + // No-op + } + + [BeforeScenario("@tag2 and @tag1")] + public void BeforeScenario_Tag1AndTag2(IReqnrollOutputHelper reqnrollOutputHelper) + { + reqnrollOutputHelper.WriteLine("BeforeScenario with @tag2 and @tag1 executed"); + } + + [BeforeScenario("@tag1 and not @tag3")] + public void BeforeScenario_Tag1AndNotTag3(IReqnrollOutputHelper reqnrollOutputHelper) + { + reqnrollOutputHelper.WriteLine("BeforeScenario with @tag1 and not @tag3 executed"); + } +} diff --git a/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.feature b/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.feature new file mode 100644 index 000000000..8840d4808 --- /dev/null +++ b/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.feature @@ -0,0 +1,11 @@ +Feature: tag_expressions + +This demonstrates use of tag expressions in the binding code + +@tag1 @tag2 +Scenario: Multiple tags Scenario + Given something + +@tag1 +Scenario: Single tag Scenario + Given something else \ No newline at end of file diff --git a/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.ndjson b/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.ndjson new file mode 100644 index 000000000..e813e9491 --- /dev/null +++ b/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.ndjson @@ -0,0 +1,30 @@ +{"testRunStarted":{"timestamp":{"seconds":1767969864,"nanos":496985000},"id":"bRSwiaInAaNtSGrxQ26NGg"}} +{"meta":{"protocolVersion":"30.1.0","implementation":{"name":"Reqnroll","version":"3.3.2-local"},"runtime":{"name":"dotNet","version":"8.0.22"},"os":{"name":"Windows","version":"Microsoft Windows 10.0.26200"},"cpu":{"name":"x64"},"ci":{"name":"Azure Pipelines","url":"/_build/results?buildId=&_a=summary","buildNumber":"20231001.1","git":{"remote":"https://dev.azure.com/reqnroll/reqnroll/_git/reqnroll","revision":"main","branch":"1b1c2588e46d5c995d54da1082b618fa13553eb3"}}}} +{"stepDefinition":{"id":"dhRLlHi7lRRDpSJFBVrE4A","pattern":{"source":"something","type":"CUCUMBER_EXPRESSION"},"sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"GivenSomething","methodParameterTypes":[]}}}} +{"stepDefinition":{"id":"81F7yjubZ7_8e7QRVhMv0Q","pattern":{"source":"something else","type":"CUCUMBER_EXPRESSION"},"sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"GivenSomethingElse","methodParameterTypes":[]}}}} +{"hook":{"id":"Tg7Dkp6sdwwGm_br7xMBow","sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"BeforeScenario_Tag1AndTag2","methodParameterTypes":["IReqnrollOutputHelper"]}},"tagExpression":"( @tag2 and @tag1 )","type":"BEFORE_TEST_CASE"}} +{"hook":{"id":"M-xZyqHpbmKvaed6Wd_o_g","sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"BeforeScenario_Tag1AndNotTag3","methodParameterTypes":["IReqnrollOutputHelper"]}},"tagExpression":"( @tag1 and not ( @tag3 ) )","type":"BEFORE_TEST_CASE"}} +{"source":{"uri":"FeatureFileb9f74128b2e34d0f90b3094850ed4e84.feature","data":"Feature: tag_expressions\r\n\r\nThis demonstrates use of tag expressions in the binding code\r\n\r\n@tag1 @tag2\r\nScenario: Multiple tags Scenario\r\n\tGiven something\r\n\r\n@tag1\r\nScenario: Single tag Scenario\r\n\tGiven something else","mediaType":"text/x.cucumber.gherkin+plain"}} +{"gherkinDocument":{"uri":"FeatureFileb9f74128b2e34d0f90b3094850ed4e84.feature","feature":{"location":{"line":1,"column":1},"tags":[],"language":"en-US","keyword":"Feature","name":"tag_expressions","description":"This demonstrates use of tag expressions in the binding code","children":[{"scenario":{"location":{"line":6,"column":1},"tags":[{"location":{"line":5,"column":1},"name":"@tag1","id":"a2083bb3d3ec7e59987d654d3ea5b036"},{"location":{"line":5,"column":7},"name":"@tag2","id":"74584f1898fd035b9d652243be10bf03"}],"keyword":"Scenario","name":"Multiple tags Scenario","description":"","steps":[{"location":{"line":7,"column":2},"keyword":"Given ","keywordType":"Context","text":"something","id":"2abb892b35577852b30d56f45fafcca6"}],"examples":[],"id":"ece4621162ed855a88e4fc6e70649b21"}},{"scenario":{"location":{"line":10,"column":1},"tags":[{"location":{"line":9,"column":1},"name":"@tag1","id":"ea929e908274ba5992f89f9b1b7ac108"}],"keyword":"Scenario","name":"Single tag Scenario","description":"","steps":[{"location":{"line":11,"column":2},"keyword":"Given ","keywordType":"Context","text":"something else","id":"8364e91db56ce753a8171a9d94f7b932"}],"examples":[],"id":"a0c8dc85a3a92d50a753faca2fbe6988"}}]},"comments":[]}} +{"pickle":{"id":"6f5e1efb11b5f65e81fcf1eb5f99f1cb","uri":"FeatureFileb9f74128b2e34d0f90b3094850ed4e84.feature","name":"Multiple tags Scenario","language":"en-US","steps":[{"astNodeIds":["2abb892b35577852b30d56f45fafcca6"],"id":"43d53ae3ea4d0759bef437904717111c","type":"Context","text":"something"}],"tags":[{"name":"@tag1","astNodeId":"a2083bb3d3ec7e59987d654d3ea5b036"},{"name":"@tag2","astNodeId":"74584f1898fd035b9d652243be10bf03"}],"astNodeIds":["ece4621162ed855a88e4fc6e70649b21"]}} +{"pickle":{"id":"0e2b0c6b58572158ad5cb95076db4d0c","uri":"FeatureFileb9f74128b2e34d0f90b3094850ed4e84.feature","name":"Single tag Scenario","language":"en-US","steps":[{"astNodeIds":["8364e91db56ce753a8171a9d94f7b932"],"id":"d8c0458aa6f5f95b872d901c54e9abe4","type":"Context","text":"something else"}],"tags":[{"name":"@tag1","astNodeId":"ea929e908274ba5992f89f9b1b7ac108"}],"astNodeIds":["a0c8dc85a3a92d50a753faca2fbe6988"]}} +{"testCase":{"id":"hk0v-PL6pvYQ5iAgFJityw","pickleId":"6f5e1efb11b5f65e81fcf1eb5f99f1cb","testSteps":[{"hookId":"Tg7Dkp6sdwwGm_br7xMBow","id":"8B4wKp7IPGjIKxk9yPXtXg"},{"hookId":"M-xZyqHpbmKvaed6Wd_o_g","id":"kLYLbx1I-dguJ6AOonxVQw"},{"id":"BJDuKzddm-Z9vtwWxkT20g","pickleStepId":"43d53ae3ea4d0759bef437904717111c","stepDefinitionIds":["dhRLlHi7lRRDpSJFBVrE4A"],"stepMatchArgumentsLists":[{"stepMatchArguments":[]}]}],"testRunStartedId":"bRSwiaInAaNtSGrxQ26NGg"}} +{"testCaseStarted":{"attempt":0,"id":"k_tv2q_87GWLOWZ3oTCkfQ","testCaseId":"hk0v-PL6pvYQ5iAgFJityw","timestamp":{"seconds":1767969864,"nanos":695178500}}} +{"testStepStarted":{"testCaseStartedId":"k_tv2q_87GWLOWZ3oTCkfQ","testStepId":"8B4wKp7IPGjIKxk9yPXtXg","timestamp":{"seconds":1767969864,"nanos":702375300}}} +{"attachment":{"body":"BeforeScenario with @tag2 and @tag1 executed","contentEncoding":"IDENTITY","mediaType":"text/x.cucumber.log+plain","testCaseStartedId":"k_tv2q_87GWLOWZ3oTCkfQ","testStepId":"8B4wKp7IPGjIKxk9yPXtXg","testRunStartedId":"bRSwiaInAaNtSGrxQ26NGg","testRunHookStartedId":"","timestamp":{"seconds":1767969864,"nanos":711465800}}} +{"testStepFinished":{"testCaseStartedId":"k_tv2q_87GWLOWZ3oTCkfQ","testStepId":"8B4wKp7IPGjIKxk9yPXtXg","testStepResult":{"duration":{"seconds":0,"nanos":11469500},"status":"PASSED"},"timestamp":{"seconds":1767969864,"nanos":713844800}}} +{"testStepStarted":{"testCaseStartedId":"k_tv2q_87GWLOWZ3oTCkfQ","testStepId":"kLYLbx1I-dguJ6AOonxVQw","timestamp":{"seconds":1767969864,"nanos":716500100}}} +{"attachment":{"body":"BeforeScenario with @tag1 and not @tag3 executed","contentEncoding":"IDENTITY","mediaType":"text/x.cucumber.log+plain","testCaseStartedId":"k_tv2q_87GWLOWZ3oTCkfQ","testStepId":"kLYLbx1I-dguJ6AOonxVQw","testRunStartedId":"bRSwiaInAaNtSGrxQ26NGg","testRunHookStartedId":"","timestamp":{"seconds":1767969864,"nanos":717397400}}} +{"testStepFinished":{"testCaseStartedId":"k_tv2q_87GWLOWZ3oTCkfQ","testStepId":"kLYLbx1I-dguJ6AOonxVQw","testStepResult":{"duration":{"seconds":0,"nanos":992400},"status":"PASSED"},"timestamp":{"seconds":1767969864,"nanos":717492500}}} +{"testStepStarted":{"testCaseStartedId":"k_tv2q_87GWLOWZ3oTCkfQ","testStepId":"BJDuKzddm-Z9vtwWxkT20g","timestamp":{"seconds":1767969864,"nanos":727771300}}} +{"testStepFinished":{"testCaseStartedId":"k_tv2q_87GWLOWZ3oTCkfQ","testStepId":"BJDuKzddm-Z9vtwWxkT20g","testStepResult":{"duration":{"seconds":0,"nanos":5367500},"status":"PASSED"},"timestamp":{"seconds":1767969864,"nanos":733138800}}} +{"testCaseFinished":{"testCaseStartedId":"k_tv2q_87GWLOWZ3oTCkfQ","timestamp":{"seconds":1767969864,"nanos":739876700},"willBeRetried":false}} +{"testCase":{"id":"6vGXEpNFy5ZYrcITUkBVGQ","pickleId":"0e2b0c6b58572158ad5cb95076db4d0c","testSteps":[{"hookId":"M-xZyqHpbmKvaed6Wd_o_g","id":"yTo5YpTmDpt1PKumg3UFaA"},{"id":"b7x0SoYUfReCkZsCC56Qqw","pickleStepId":"d8c0458aa6f5f95b872d901c54e9abe4","stepDefinitionIds":["81F7yjubZ7_8e7QRVhMv0Q"],"stepMatchArgumentsLists":[{"stepMatchArguments":[]}]}],"testRunStartedId":"bRSwiaInAaNtSGrxQ26NGg"}} +{"testCaseStarted":{"attempt":0,"id":"ZxcIoUGNmNopFIoKM4khKw","testCaseId":"6vGXEpNFy5ZYrcITUkBVGQ","timestamp":{"seconds":1767969864,"nanos":760685400}}} +{"testStepStarted":{"testCaseStartedId":"ZxcIoUGNmNopFIoKM4khKw","testStepId":"yTo5YpTmDpt1PKumg3UFaA","timestamp":{"seconds":1767969864,"nanos":760844300}}} +{"attachment":{"body":"BeforeScenario with @tag1 and not @tag3 executed","contentEncoding":"IDENTITY","mediaType":"text/x.cucumber.log+plain","testCaseStartedId":"ZxcIoUGNmNopFIoKM4khKw","testStepId":"yTo5YpTmDpt1PKumg3UFaA","testRunStartedId":"bRSwiaInAaNtSGrxQ26NGg","testRunHookStartedId":"","timestamp":{"seconds":1767969864,"nanos":761069300}}} +{"testStepFinished":{"testCaseStartedId":"ZxcIoUGNmNopFIoKM4khKw","testStepId":"yTo5YpTmDpt1PKumg3UFaA","testStepResult":{"duration":{"seconds":0,"nanos":248800},"status":"PASSED"},"timestamp":{"seconds":1767969864,"nanos":761093100}}} +{"testStepStarted":{"testCaseStartedId":"ZxcIoUGNmNopFIoKM4khKw","testStepId":"b7x0SoYUfReCkZsCC56Qqw","timestamp":{"seconds":1767969864,"nanos":761380600}}} +{"testStepFinished":{"testCaseStartedId":"ZxcIoUGNmNopFIoKM4khKw","testStepId":"b7x0SoYUfReCkZsCC56Qqw","testStepResult":{"duration":{"seconds":0,"nanos":523500},"status":"PASSED"},"timestamp":{"seconds":1767969864,"nanos":761904100}}} +{"testCaseFinished":{"testCaseStartedId":"ZxcIoUGNmNopFIoKM4khKw","timestamp":{"seconds":1767969864,"nanos":762084700},"willBeRetried":false}} +{"testRunFinished":{"success":true,"timestamp":{"seconds":1767969864,"nanos":771489300},"testRunStartedId":"bRSwiaInAaNtSGrxQ26NGg"}} \ No newline at end of file diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs index ed7a10eb1..6814e4583 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/ReqnrollTagExpressionParserTests.cs @@ -55,16 +55,16 @@ public void Single_term_expressions_with_at_prefix_remain_unchanged(string tagNa [InlineData("not tag1")] [InlineData("tag1 and @tag2")] [InlineData("@tag1 or tag2")] - public void Multi_term_expressions_without_at_prefix_throw_exception(string expression) + public void Multi_term_expressions_without_at_prefix_returns_InvalidTagExpression(string expression) { // Arrange var parser = CreateParser(); // Act - Action act = () => parser.Parse(expression); + var result = parser.Parse(expression); // Assert - act.Should().Throw(); + result.Should().BeOfType(); } From 4403a3eb7f8cbb3ebfce9018017a4811afb9c4b8 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Wed, 14 Jan 2026 08:57:20 -0600 Subject: [PATCH 15/19] Added properties to surface tag expression errors via BindingSourceProcessor. Added BindingSourceProcessor test for handling of bad Scope tag expressions. --- Reqnroll/Bindings/BindingFactory.cs | 4 ++-- Reqnroll/Bindings/BindingScope.cs | 7 ++++-- .../Discovery/BindingSourceProcessor.cs | 6 ++--- Reqnroll/Bindings/HookBinding.cs | 6 ++++- Reqnroll/Bindings/IBindingFactory.cs | 2 +- Reqnroll/Bindings/IHookBinding.cs | 2 ++ .../Provider/BindingProviderService.cs | 4 +++- .../Provider/Data/BindingScopeData.cs | 1 + Reqnroll/Bindings/Provider/Data/HookData.cs | 2 ++ .../Discovery/BindingSourceProcessorTests.cs | 22 +++++++++++++++++++ 10 files changed, 46 insertions(+), 10 deletions(-) diff --git a/Reqnroll/Bindings/BindingFactory.cs b/Reqnroll/Bindings/BindingFactory.cs index e7d54d347..aeb063cba 100644 --- a/Reqnroll/Bindings/BindingFactory.cs +++ b/Reqnroll/Bindings/BindingFactory.cs @@ -10,9 +10,9 @@ public class BindingFactory( : IBindingFactory { public IHookBinding CreateHookBinding(IBindingMethod bindingMethod, HookType hookType, BindingScope bindingScope, - int hookOrder) + int hookOrder, string errorMessage = null) { - return new HookBinding(bindingMethod, hookType, bindingScope, hookOrder); + return new HookBinding(bindingMethod, hookType, bindingScope, hookOrder, errorMessage); } public IStepDefinitionBindingBuilder CreateStepDefinitionBindingBuilder(StepDefinitionType stepDefinitionType, IBindingMethod bindingMethod, BindingScope bindingScope, diff --git a/Reqnroll/Bindings/BindingScope.cs b/Reqnroll/Bindings/BindingScope.cs index f89e0842a..cc5ed704e 100644 --- a/Reqnroll/Bindings/BindingScope.cs +++ b/Reqnroll/Bindings/BindingScope.cs @@ -4,7 +4,7 @@ namespace Reqnroll.Bindings { - public class BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle) + public class BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle, string errorMessage = null) { public string Tag => tagExpression.ToString(); public ITagExpression TagExpression => tagExpression; @@ -12,6 +12,8 @@ public class BindingScope(ITagExpression tagExpression, string featureTitle, str public string FeatureTitle { get; } = featureTitle; public string ScenarioTitle { get; } = scenarioTitle; + public bool IsValid => ErrorMessage == null; + public string ErrorMessage => errorMessage; public bool Match(StepContext stepContext, out int scopeMatches) { @@ -46,7 +48,7 @@ public bool Match(StepContext stepContext, out int scopeMatches) protected bool Equals(BindingScope other) { - return string.Equals(Tag, other.Tag) && string.Equals(FeatureTitle, other.FeatureTitle) && string.Equals(ScenarioTitle, other.ScenarioTitle); + return string.Equals(Tag, other.Tag) && string.Equals(FeatureTitle, other.FeatureTitle) && string.Equals(ScenarioTitle, other.ScenarioTitle) && string.Equals(ErrorMessage, other.ErrorMessage); } public override bool Equals(object obj) @@ -64,6 +66,7 @@ public override int GetHashCode() var hashCode = (Tag != null ? Tag.GetHashCode() : 0); hashCode = (hashCode*397) ^ (FeatureTitle != null ? FeatureTitle.GetHashCode() : 0); hashCode = (hashCode*397) ^ (ScenarioTitle != null ? ScenarioTitle.GetHashCode() : 0); + hashCode = (hashCode*397) ^ (ErrorMessage != null ? ErrorMessage.GetHashCode() : 0); return hashCode; } } diff --git a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs index 4a27248dd..1a2d258a7 100644 --- a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs +++ b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs @@ -182,15 +182,15 @@ private void ProcessHookAttribute(BindingSourceMethod bindingSourceMethod, Bindi int order = GetHookOrder(hookAttribute); var validationResult = ValidateHook(bindingSourceMethod, hookAttribute, hookType); - validationResult += ValidateBindingScope(scope); + var scopeValidationResult = ValidateBindingScope(scope); if (!validationResult.IsValid) { - OnValidationError(validationResult, true); + OnValidationError(validationResult + scopeValidationResult, true); return; } - var hookBinding = _bindingFactory.CreateHookBinding(bindingSourceMethod.BindingMethod, hookType, scope, order); + var hookBinding = _bindingFactory.CreateHookBinding(bindingSourceMethod.BindingMethod, hookType, scope, order, scopeValidationResult.IsValid ? null : scopeValidationResult.ErrorMessages[0]); ProcessHookBinding(hookBinding); } diff --git a/Reqnroll/Bindings/HookBinding.cs b/Reqnroll/Bindings/HookBinding.cs index ddcbb4c8f..7ac42e3b1 100644 --- a/Reqnroll/Bindings/HookBinding.cs +++ b/Reqnroll/Bindings/HookBinding.cs @@ -9,11 +9,15 @@ public class HookBinding : MethodBinding, IHookBinding public BindingScope BindingScope { get; private set; } public bool IsScoped { get { return BindingScope != null; } } - public HookBinding(IBindingMethod bindingMethod, HookType hookType, BindingScope bindingScope, int hookOrder) : base(bindingMethod) + public bool IsValid => ErrorMessage == null; + public string ErrorMessage { get; } + + public HookBinding(IBindingMethod bindingMethod, HookType hookType, BindingScope bindingScope, int hookOrder, string errorMessage = null) : base(bindingMethod) { HookOrder = hookOrder; HookType = hookType; BindingScope = bindingScope; + ErrorMessage = errorMessage; } protected bool Equals(HookBinding other) diff --git a/Reqnroll/Bindings/IBindingFactory.cs b/Reqnroll/Bindings/IBindingFactory.cs index 6ed5006aa..b93542076 100644 --- a/Reqnroll/Bindings/IBindingFactory.cs +++ b/Reqnroll/Bindings/IBindingFactory.cs @@ -5,7 +5,7 @@ namespace Reqnroll.Bindings public interface IBindingFactory { IHookBinding CreateHookBinding(IBindingMethod bindingMethod, HookType hookType, BindingScope bindingScope, - int hookOrder); + int hookOrder, string errorMessage = null); IStepDefinitionBindingBuilder CreateStepDefinitionBindingBuilder(StepDefinitionType stepDefinitionType, IBindingMethod bindingMethod, BindingScope bindingScope, string expressionString, ExpressionType expressionType); diff --git a/Reqnroll/Bindings/IHookBinding.cs b/Reqnroll/Bindings/IHookBinding.cs index 32b1f0923..8ab0e5167 100644 --- a/Reqnroll/Bindings/IHookBinding.cs +++ b/Reqnroll/Bindings/IHookBinding.cs @@ -4,5 +4,7 @@ public interface IHookBinding : IScopedBinding, IBinding { HookType HookType { get; } int HookOrder { get; } + bool IsValid { get; } + string ErrorMessage { get; } } } \ No newline at end of file diff --git a/Reqnroll/Bindings/Provider/BindingProviderService.cs b/Reqnroll/Bindings/Provider/BindingProviderService.cs index 3d8b16af3..e6729b738 100644 --- a/Reqnroll/Bindings/Provider/BindingProviderService.cs +++ b/Reqnroll/Bindings/Provider/BindingProviderService.cs @@ -108,6 +108,7 @@ HookData CreateHook(IHookBinding hookBinding) Type = hookBinding.HookType.ToString(), HookOrder = hookBinding.HookOrder, Scope = GetScope(hookBinding), + Error = hookBinding.ErrorMessage }; return hook; @@ -141,7 +142,8 @@ BindingScopeData GetScope(IScopedBinding scopedBinding) { Tag = string.IsNullOrEmpty(tagScope) ? null : tagScope, FeatureTitle = scopedBinding.BindingScope.FeatureTitle, - ScenarioTitle = scopedBinding.BindingScope.ScenarioTitle + ScenarioTitle = scopedBinding.BindingScope.ScenarioTitle, + Error = scopedBinding.BindingScope.ErrorMessage }; } diff --git a/Reqnroll/Bindings/Provider/Data/BindingScopeData.cs b/Reqnroll/Bindings/Provider/Data/BindingScopeData.cs index b8ec8cbd1..ab0811456 100644 --- a/Reqnroll/Bindings/Provider/Data/BindingScopeData.cs +++ b/Reqnroll/Bindings/Provider/Data/BindingScopeData.cs @@ -6,4 +6,5 @@ public class BindingScopeData public string Tag { get; set; } public string FeatureTitle { get; set; } public string ScenarioTitle { get; set; } + public string Error { get; set; } } diff --git a/Reqnroll/Bindings/Provider/Data/HookData.cs b/Reqnroll/Bindings/Provider/Data/HookData.cs index f2b121890..5401500c9 100644 --- a/Reqnroll/Bindings/Provider/Data/HookData.cs +++ b/Reqnroll/Bindings/Provider/Data/HookData.cs @@ -6,4 +6,6 @@ public class HookData public BindingScopeData Scope { get; set; } public string Type { get; set; } public int HookOrder { get; set; } + public string Error { get; set; } + } diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs index f795a843a..60fd8eaa9 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs @@ -94,6 +94,13 @@ private BindingMethod CreateSyntheticBindingMethod() return new BindingMethod(bindingType, "MyMethod", Array.Empty(), RuntimeBindingType.Void); } + private BindingSourceAttribute CreateSyntheticScopeBindingSourceAttributeWithTagExpression(string tagExpression) + { + var bsAttribute = CreateBindingSourceAttribute("ScopeAttribute", "Reqnroll.ScopeAttribute"); + bsAttribute.NamedAttributeValues.Add("Tag", new BindingSourceAttributeValueProvider(tagExpression)); + return bsAttribute; + } + [Binding] class StepDefClassWithAsyncVoid { @@ -175,6 +182,21 @@ public void Non_static_feature_and_test_run_hook_errors_should_be_captured(HookT sut.ValidationErrors.Should().Contain(m => m.Contains("The binding methods for before/after feature and before/after test run events must be static")); } + [Fact] + public void InvalidScopeTagExpressionsOnBindingMethodErrors_should_be_captured() + { + var sut = CreateBindingSourceProcessor(); + var bindingSourceType = CreateSyntheticBindingSourceType(); + var bindingSourceMethod = CreateSyntheticStepDefBindingSourceMethod(); + // add invalid tag expression + var scopeAttribute = CreateSyntheticScopeBindingSourceAttributeWithTagExpression("@foo not ("); + bindingSourceMethod.Attributes = bindingSourceMethod.Attributes.Append(scopeAttribute).ToArray(); + sut.ProcessType(bindingSourceType).Should().BeTrue(); + sut.ProcessMethod(bindingSourceMethod); + sut.BuildingCompleted(); + sut.ValidationErrors.Should().Contain(m => m.Contains("could not be parsed because of syntax error")); + } + private static BindingSourceMethod CreateBindingSourceMethod(Type bindingType, string methodName, params BindingSourceAttribute[] attributes) { var methodInfo = bindingType.GetMethod(methodName); From ec0263a89f26f6901e7d994495a0c2ff38bab7c9 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Wed, 14 Jan 2026 13:05:30 -0600 Subject: [PATCH 16/19] Refactored tag expression support with addition of a ReqnollTagExpression that tracks the original tag text. BindingScope now pulls its ErrorMessage directly from its input tagExpression. Fixed errors in BindingSourceProcessor handling of scope errors on hooks. Added tests to BindingSourceProcessorTests demonstrating handling of bad scope expressions. --- Reqnroll/Bindings/BindingScope.cs | 5 +++-- .../Discovery/BindingSourceProcessor.cs | 6 +++--- .../Discovery/InvalidTagExpression.cs | 9 +++----- .../Discovery/ReqnrollTagExpression.cs | 21 +++++++++++++++++++ .../Discovery/ReqnrollTagExpressionParser.cs | 8 ++++--- .../Discovery/BindingSourceProcessorTests.cs | 15 +++++++++++++ 6 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs diff --git a/Reqnroll/Bindings/BindingScope.cs b/Reqnroll/Bindings/BindingScope.cs index cc5ed704e..aa24d0fbc 100644 --- a/Reqnroll/Bindings/BindingScope.cs +++ b/Reqnroll/Bindings/BindingScope.cs @@ -1,10 +1,11 @@ using System; using System.Linq; using Cucumber.TagExpressions; +using Reqnroll.Bindings.Discovery; namespace Reqnroll.Bindings { - public class BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle, string errorMessage = null) + public class BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle) { public string Tag => tagExpression.ToString(); public ITagExpression TagExpression => tagExpression; @@ -13,7 +14,7 @@ public class BindingScope(ITagExpression tagExpression, string featureTitle, str public string ScenarioTitle { get; } = scenarioTitle; public bool IsValid => ErrorMessage == null; - public string ErrorMessage => errorMessage; + public string ErrorMessage => tagExpression is InvalidTagExpression ? tagExpression.ToString() : null; public bool Match(StepContext stepContext, out int scopeMatches) { diff --git a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs index 1a2d258a7..4973d2e1c 100644 --- a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs +++ b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs @@ -183,11 +183,11 @@ private void ProcessHookAttribute(BindingSourceMethod bindingSourceMethod, Bindi var validationResult = ValidateHook(bindingSourceMethod, hookAttribute, hookType); var scopeValidationResult = ValidateBindingScope(scope); - + validationResult += scopeValidationResult; if (!validationResult.IsValid) { - OnValidationError(validationResult + scopeValidationResult, true); - return; + OnValidationError(validationResult, true); + } var hookBinding = _bindingFactory.CreateHookBinding(bindingSourceMethod.BindingMethod, hookType, scope, order, scopeValidationResult.IsValid ? null : scopeValidationResult.ErrorMessages[0]); diff --git a/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs b/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs index be519e518..5cca5ff84 100644 --- a/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs +++ b/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs @@ -2,17 +2,14 @@ using System; namespace Reqnroll.Bindings.Discovery; - -public class InvalidTagExpression : ITagExpression +public class InvalidTagExpression : ReqnrollTagExpression, ITagExpression { public string Message { get; } - public string OriginalTagExpression { get; } - public InvalidTagExpression(string originalTagExpression, string message) + public InvalidTagExpression(ITagExpression expression, string originalTagExpression, string message) : base(expression, originalTagExpression) { - OriginalTagExpression = originalTagExpression; Message = message; } - public bool Evaluate(System.Collections.Generic.IEnumerable tags) + public override bool Evaluate(System.Collections.Generic.IEnumerable tags) { throw new InvalidOperationException("Cannot evaluate an invalid tag expression: " + Message); } diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs new file mode 100644 index 000000000..317f75237 --- /dev/null +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs @@ -0,0 +1,21 @@ +using Cucumber.TagExpressions; +using System.Collections.Generic; + +namespace Reqnroll.Bindings.Discovery; + +public class ReqnrollTagExpression : ITagExpression +{ + public ReqnrollTagExpression(ITagExpression inner, string tagExpressionText) + { + TagExpressionText = tagExpressionText; + _inner = inner; + } + public string TagExpressionText { get; } + + private ITagExpression _inner; + + public virtual bool Evaluate(IEnumerable inputs) + { + return _inner.Evaluate(inputs); + } +} diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs index 62a2a73fe..bda801325 100644 --- a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs @@ -8,9 +8,11 @@ public class ReqnrollTagExpressionParser : IReqnrollTagExpressionParser public ITagExpression Parse(string tagExpression) { var tagExpressionParser = new TagExpressionParser(); + ITagExpression result = null; try { - var parsedExpression = tagExpressionParser.Parse(tagExpression); - return Rewrite(parsedExpression); + result = tagExpressionParser.Parse(tagExpression); + result = Rewrite(result); + return new ReqnrollTagExpression(result, tagExpression); } catch (TagExpressionException ex) { @@ -19,7 +21,7 @@ public ITagExpression Parse(string tagExpression) { msg += $" (at offset {ex.TagToken.Position})"; } - return new InvalidTagExpression(tagExpression, msg); + return new InvalidTagExpression(null, tagExpression, msg); } } diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs index 60fd8eaa9..1e8a7ad2d 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs @@ -197,6 +197,21 @@ public void InvalidScopeTagExpressionsOnBindingMethodErrors_should_be_captured() sut.ValidationErrors.Should().Contain(m => m.Contains("could not be parsed because of syntax error")); } + [Fact] + public void InvalidScopeTagExpressionsOnHookBindingMethodErrors_should_be_captured() + { + var sut = CreateBindingSourceProcessor(); + var bindingSourceType = CreateSyntheticBindingSourceType(); + var bindingSourceMethod = CreateSyntheticHookBindingSourceMethod(HookType.BeforeScenario); + // add invalid tag expression + var scopeAttribute = CreateSyntheticScopeBindingSourceAttributeWithTagExpression("@foo and or"); + bindingSourceMethod.Attributes = bindingSourceMethod.Attributes.Append(scopeAttribute).ToArray(); + sut.ProcessType(bindingSourceType).Should().BeTrue(); + sut.ProcessMethod(bindingSourceMethod); + sut.BuildingCompleted(); + sut.ValidationErrors.Should().Contain(m => m.Contains("could not be parsed because of syntax error")); + } + private static BindingSourceMethod CreateBindingSourceMethod(Type bindingType, string methodName, params BindingSourceAttribute[] attributes) { var methodInfo = bindingType.GetMethod(methodName); From fa433c47d36464caa21feae871273826cbb4a306 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:44:30 -0600 Subject: [PATCH 17/19] Fix ReqnrollTagExpression missing ToString() override --- Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs index 317f75237..3d2f72db1 100644 --- a/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs @@ -5,14 +5,19 @@ namespace Reqnroll.Bindings.Discovery; public class ReqnrollTagExpression : ITagExpression { + public string TagExpressionText { get; } + private ITagExpression _inner; + public ReqnrollTagExpression(ITagExpression inner, string tagExpressionText) { TagExpressionText = tagExpressionText; _inner = inner; } - public string TagExpressionText { get; } - private ITagExpression _inner; + public override string ToString() + { + return _inner.ToString(); + } public virtual bool Evaluate(IEnumerable inputs) { From 813d9c2dd771ec6f7cdb8f33acbebfdd025a9733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1sp=C3=A1r=20Nagy?= Date: Fri, 16 Jan 2026 15:05:35 +0100 Subject: [PATCH 18/19] small fixes --- Reqnroll/Bindings/BindingScope.cs | 2 +- .../Bindings/Discovery/BindingSourceProcessor.cs | 8 ++++---- .../Bindings/Discovery/InvalidTagExpression.cs | 11 ++++------- .../Bindings/Discovery/ReqnrollTagExpression.cs | 15 ++++----------- .../Discovery/ReqnrollTagExpressionParser.cs | 8 ++++---- Reqnroll/Bindings/HookBinding.cs | 3 ++- Reqnroll/Bindings/Provider/Data/HookData.cs | 1 - .../Discovery/BindingSourceProcessorTests.cs | 6 +++--- 8 files changed, 22 insertions(+), 32 deletions(-) diff --git a/Reqnroll/Bindings/BindingScope.cs b/Reqnroll/Bindings/BindingScope.cs index aa24d0fbc..f9a0bf990 100644 --- a/Reqnroll/Bindings/BindingScope.cs +++ b/Reqnroll/Bindings/BindingScope.cs @@ -7,7 +7,7 @@ namespace Reqnroll.Bindings { public class BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle) { - public string Tag => tagExpression.ToString(); + public string Tag => tagExpression is ReqnrollTagExpression reqnrollTagExpression ? reqnrollTagExpression.TagExpressionText : tagExpression.ToString(); public ITagExpression TagExpression => tagExpression; public string FeatureTitle { get; } = featureTitle; diff --git a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs index 4973d2e1c..ff83735f6 100644 --- a/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs +++ b/Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs @@ -187,10 +187,10 @@ private void ProcessHookAttribute(BindingSourceMethod bindingSourceMethod, Bindi if (!validationResult.IsValid) { OnValidationError(validationResult, true); - } - var hookBinding = _bindingFactory.CreateHookBinding(bindingSourceMethod.BindingMethod, hookType, scope, order, scopeValidationResult.IsValid ? null : scopeValidationResult.ErrorMessages[0]); + var hookBinding = _bindingFactory.CreateHookBinding(bindingSourceMethod.BindingMethod, hookType, scope, order, + scopeValidationResult.IsValid ? null : scopeValidationResult.CombinedErrorMessages); ProcessHookBinding(hookBinding); } @@ -363,9 +363,9 @@ protected virtual BindingValidationResult ValidateBindingScope(BindingScope bind { var result = BindingValidationResult.Valid; - if (bindingScope != null && bindingScope.TagExpression is InvalidTagExpression invalidTagExpression) + if (bindingScope is { TagExpression: InvalidTagExpression invalidTagExpression }) { - result += BindingValidationResult.Error(invalidTagExpression.Message); + result += BindingValidationResult.Error($"Invalid scope: {invalidTagExpression}"); } return result; } diff --git a/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs b/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs index 5cca5ff84..235930ba1 100644 --- a/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs +++ b/Reqnroll/Bindings/Discovery/InvalidTagExpression.cs @@ -2,19 +2,16 @@ using System; namespace Reqnroll.Bindings.Discovery; -public class InvalidTagExpression : ReqnrollTagExpression, ITagExpression +public class InvalidTagExpression(ITagExpression expression, string originalTagExpression, string message) : ReqnrollTagExpression(expression, originalTagExpression) { - public string Message { get; } - public InvalidTagExpression(ITagExpression expression, string originalTagExpression, string message) : base(expression, originalTagExpression) - { - Message = message; - } + public string Message { get; } = message; + public override bool Evaluate(System.Collections.Generic.IEnumerable tags) { throw new InvalidOperationException("Cannot evaluate an invalid tag expression: " + Message); } public override string ToString() { - return "Invalid Tag Expression: " + Message; + return "Invalid tag expression: " + Message; } } diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs index 3d2f72db1..7158abc6d 100644 --- a/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpression.cs @@ -3,24 +3,17 @@ namespace Reqnroll.Bindings.Discovery; -public class ReqnrollTagExpression : ITagExpression +public class ReqnrollTagExpression(ITagExpression inner, string tagExpressionText) : ITagExpression { - public string TagExpressionText { get; } - private ITagExpression _inner; - - public ReqnrollTagExpression(ITagExpression inner, string tagExpressionText) - { - TagExpressionText = tagExpressionText; - _inner = inner; - } + public string TagExpressionText { get; } = tagExpressionText; public override string ToString() { - return _inner.ToString(); + return inner.ToString(); } public virtual bool Evaluate(IEnumerable inputs) { - return _inner.Evaluate(inputs); + return inner.Evaluate(inputs); } } diff --git a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs index bda801325..0a516859c 100644 --- a/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs +++ b/Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs @@ -8,10 +8,10 @@ public class ReqnrollTagExpressionParser : IReqnrollTagExpressionParser public ITagExpression Parse(string tagExpression) { var tagExpressionParser = new TagExpressionParser(); - ITagExpression result = null; - try { - result = tagExpressionParser.Parse(tagExpression); - result = Rewrite(result); + try + { + var result = tagExpressionParser.Parse(tagExpression); + result = Rewrite(result); return new ReqnrollTagExpression(result, tagExpression); } catch (TagExpressionException ex) diff --git a/Reqnroll/Bindings/HookBinding.cs b/Reqnroll/Bindings/HookBinding.cs index 7ac42e3b1..e0e026674 100644 --- a/Reqnroll/Bindings/HookBinding.cs +++ b/Reqnroll/Bindings/HookBinding.cs @@ -22,7 +22,7 @@ public HookBinding(IBindingMethod bindingMethod, HookType hookType, BindingScope protected bool Equals(HookBinding other) { - return HookType == other.HookType && HookOrder == other.HookOrder && Equals(BindingScope, other.BindingScope) && base.Equals(other); + return HookType == other.HookType && HookOrder == other.HookOrder && Equals(BindingScope, other.BindingScope) && string.Equals(ErrorMessage, other.ErrorMessage) && base.Equals(other); } public override bool Equals(object obj) @@ -40,6 +40,7 @@ public override int GetHashCode() var hashCode = (int) HookType; hashCode = (hashCode*397) ^ HookOrder; hashCode = (hashCode*397) ^ (BindingScope != null ? BindingScope.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (ErrorMessage != null ? ErrorMessage.GetHashCode() : 0); return hashCode; } } diff --git a/Reqnroll/Bindings/Provider/Data/HookData.cs b/Reqnroll/Bindings/Provider/Data/HookData.cs index 5401500c9..21e81cc0f 100644 --- a/Reqnroll/Bindings/Provider/Data/HookData.cs +++ b/Reqnroll/Bindings/Provider/Data/HookData.cs @@ -7,5 +7,4 @@ public class HookData public string Type { get; set; } public int HookOrder { get; set; } public string Error { get; set; } - } diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs index 1e8a7ad2d..4cd53b88a 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/Discovery/BindingSourceProcessorTests.cs @@ -183,7 +183,7 @@ public void Non_static_feature_and_test_run_hook_errors_should_be_captured(HookT } [Fact] - public void InvalidScopeTagExpressionsOnBindingMethodErrors_should_be_captured() + public void InvalidScopeTagExpressionsOnStepDefBindingMethodErrors_should_be_captured() { var sut = CreateBindingSourceProcessor(); var bindingSourceType = CreateSyntheticBindingSourceType(); @@ -194,7 +194,7 @@ public void InvalidScopeTagExpressionsOnBindingMethodErrors_should_be_captured() sut.ProcessType(bindingSourceType).Should().BeTrue(); sut.ProcessMethod(bindingSourceMethod); sut.BuildingCompleted(); - sut.ValidationErrors.Should().Contain(m => m.Contains("could not be parsed because of syntax error")); + sut.ValidationErrors.Should().Contain(m => m.Contains("Invalid scope") && m.Contains("could not be parsed because of syntax error")); } [Fact] @@ -209,7 +209,7 @@ public void InvalidScopeTagExpressionsOnHookBindingMethodErrors_should_be_captur sut.ProcessType(bindingSourceType).Should().BeTrue(); sut.ProcessMethod(bindingSourceMethod); sut.BuildingCompleted(); - sut.ValidationErrors.Should().Contain(m => m.Contains("could not be parsed because of syntax error")); + sut.ValidationErrors.Should().Contain(m => m.Contains("Invalid scope") && m.Contains("could not be parsed because of syntax error")); } private static BindingSourceMethod CreateBindingSourceMethod(Type bindingType, string methodName, params BindingSourceAttribute[] attributes) From e9566b464d79b7ee3685969bf3c989aa7c424bd5 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:03:33 -0600 Subject: [PATCH 19/19] Adjusted acceptance test file for tag-expressions Formatters test scenario. --- .../Samples/Resources/tag-expressions/tag_expressions.ndjson | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.ndjson b/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.ndjson index e813e9491..1f3b69a49 100644 --- a/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.ndjson +++ b/Tests/Reqnroll.Formatters.Tests/Samples/Resources/tag-expressions/tag_expressions.ndjson @@ -2,8 +2,8 @@ {"meta":{"protocolVersion":"30.1.0","implementation":{"name":"Reqnroll","version":"3.3.2-local"},"runtime":{"name":"dotNet","version":"8.0.22"},"os":{"name":"Windows","version":"Microsoft Windows 10.0.26200"},"cpu":{"name":"x64"},"ci":{"name":"Azure Pipelines","url":"/_build/results?buildId=&_a=summary","buildNumber":"20231001.1","git":{"remote":"https://dev.azure.com/reqnroll/reqnroll/_git/reqnroll","revision":"main","branch":"1b1c2588e46d5c995d54da1082b618fa13553eb3"}}}} {"stepDefinition":{"id":"dhRLlHi7lRRDpSJFBVrE4A","pattern":{"source":"something","type":"CUCUMBER_EXPRESSION"},"sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"GivenSomething","methodParameterTypes":[]}}}} {"stepDefinition":{"id":"81F7yjubZ7_8e7QRVhMv0Q","pattern":{"source":"something else","type":"CUCUMBER_EXPRESSION"},"sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"GivenSomethingElse","methodParameterTypes":[]}}}} -{"hook":{"id":"Tg7Dkp6sdwwGm_br7xMBow","sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"BeforeScenario_Tag1AndTag2","methodParameterTypes":["IReqnrollOutputHelper"]}},"tagExpression":"( @tag2 and @tag1 )","type":"BEFORE_TEST_CASE"}} -{"hook":{"id":"M-xZyqHpbmKvaed6Wd_o_g","sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"BeforeScenario_Tag1AndNotTag3","methodParameterTypes":["IReqnrollOutputHelper"]}},"tagExpression":"( @tag1 and not ( @tag3 ) )","type":"BEFORE_TEST_CASE"}} +{"hook":{"id":"Tg7Dkp6sdwwGm_br7xMBow","sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"BeforeScenario_Tag1AndTag2","methodParameterTypes":["IReqnrollOutputHelper"]}},"tagExpression":"@tag2 and @tag1","type":"BEFORE_TEST_CASE"}} +{"hook":{"id":"M-xZyqHpbmKvaed6Wd_o_g","sourceReference":{"javaMethod":{"className":"DefaultTestProject.Reqnroll.Formatters.Tests.Samples.Resources.tag_expressions.tag_expressions","methodName":"BeforeScenario_Tag1AndNotTag3","methodParameterTypes":["IReqnrollOutputHelper"]}},"tagExpression":"@tag1 and not @tag3","type":"BEFORE_TEST_CASE"}} {"source":{"uri":"FeatureFileb9f74128b2e34d0f90b3094850ed4e84.feature","data":"Feature: tag_expressions\r\n\r\nThis demonstrates use of tag expressions in the binding code\r\n\r\n@tag1 @tag2\r\nScenario: Multiple tags Scenario\r\n\tGiven something\r\n\r\n@tag1\r\nScenario: Single tag Scenario\r\n\tGiven something else","mediaType":"text/x.cucumber.gherkin+plain"}} {"gherkinDocument":{"uri":"FeatureFileb9f74128b2e34d0f90b3094850ed4e84.feature","feature":{"location":{"line":1,"column":1},"tags":[],"language":"en-US","keyword":"Feature","name":"tag_expressions","description":"This demonstrates use of tag expressions in the binding code","children":[{"scenario":{"location":{"line":6,"column":1},"tags":[{"location":{"line":5,"column":1},"name":"@tag1","id":"a2083bb3d3ec7e59987d654d3ea5b036"},{"location":{"line":5,"column":7},"name":"@tag2","id":"74584f1898fd035b9d652243be10bf03"}],"keyword":"Scenario","name":"Multiple tags Scenario","description":"","steps":[{"location":{"line":7,"column":2},"keyword":"Given ","keywordType":"Context","text":"something","id":"2abb892b35577852b30d56f45fafcca6"}],"examples":[],"id":"ece4621162ed855a88e4fc6e70649b21"}},{"scenario":{"location":{"line":10,"column":1},"tags":[{"location":{"line":9,"column":1},"name":"@tag1","id":"ea929e908274ba5992f89f9b1b7ac108"}],"keyword":"Scenario","name":"Single tag Scenario","description":"","steps":[{"location":{"line":11,"column":2},"keyword":"Given ","keywordType":"Context","text":"something else","id":"8364e91db56ce753a8171a9d94f7b932"}],"examples":[],"id":"a0c8dc85a3a92d50a753faca2fbe6988"}}]},"comments":[]}} {"pickle":{"id":"6f5e1efb11b5f65e81fcf1eb5f99f1cb","uri":"FeatureFileb9f74128b2e34d0f90b3094850ed4e84.feature","name":"Multiple tags Scenario","language":"en-US","steps":[{"astNodeIds":["2abb892b35577852b30d56f45fafcca6"],"id":"43d53ae3ea4d0759bef437904717111c","type":"Context","text":"something"}],"tags":[{"name":"@tag1","astNodeId":"a2083bb3d3ec7e59987d654d3ea5b036"},{"name":"@tag2","astNodeId":"74584f1898fd035b9d652243be10bf03"}],"astNodeIds":["ece4621162ed855a88e4fc6e70649b21"]}}