Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
44fd54a
Initial commit. This adds support for tag exprssions on Scopes assign…
clrudolphi Nov 4, 2025
416be89
Fix an issue in which the BindingProviderService (as invoked OOP and …
clrudolphi Nov 5, 2025
bf7d969
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Nov 9, 2025
20b0f08
Draft update to the documentation on the topic of tag expressions
clrudolphi Nov 9, 2025
71e6fcc
File scope namespace for the ReqnrollTagExpressionParser.
clrudolphi Nov 10, 2025
cf3a071
Merge branch 'Documentation_for_Tag_Expressions' into Exploratory_Tag…
clrudolphi Nov 26, 2025
467c8da
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Nov 26, 2025
7033a85
Added nuget package reference to Cucumber.TagExpressions; dropping th…
clrudolphi Nov 26, 2025
193f22c
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Nov 26, 2025
d6cbd0a
Update CHANGELOG.md
clrudolphi Nov 26, 2025
67103d0
Merge branch 'main' into Exploratory_TagExpression_Support
304NotModified Dec 15, 2025
259e77b
Update CHANGELOG.md
304NotModified Dec 15, 2025
8e44510
code cleanup
gasparnagy Dec 17, 2025
4d45031
Merge remote-tracking branch 'origin/main' into Exploratory_TagExpres…
gasparnagy Dec 17, 2025
d827d4d
Changes per review comments:
clrudolphi Jan 2, 2026
98946cf
Update scoped-bindings.md
clrudolphi Jan 2, 2026
7246af2
code cleanup
gasparnagy Jan 7, 2026
80b5986
fix BindingProviderService to work with empty Tag
gasparnagy Jan 7, 2026
f7dd139
Wrapping tag parsing errors in a new type of ITagExpression which dri…
clrudolphi Jan 7, 2026
1627faa
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Jan 8, 2026
5d060d7
Updated tests.
clrudolphi Jan 9, 2026
4403a3e
Added properties to surface tag expression errors via BindingSourcePr…
clrudolphi Jan 14, 2026
ec0263a
Refactored tag expression support with addition of a ReqnollTagExpres…
clrudolphi Jan 14, 2026
fa433c4
Fix ReqnrollTagExpression missing ToString() override
clrudolphi Jan 14, 2026
d472a88
Merge branch 'main' into Exploratory_TagExpression_Support
clrudolphi Jan 14, 2026
813d9c2
small fixes
gasparnagy Jan 16, 2026
74dfbc3
Merge remote-tracking branch 'origin/main' into Exploratory_TagExpres…
gasparnagy Jan 17, 2026
e9566b4
Adjusted acceptance test file for tag-expressions Formatters test sce…
clrudolphi Jan 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Updated NUnit3TestAdapter to v5.2.0 and NUnit to v4.4.0 in templates and tests to fix spurious warnings during test runs (#846)
* 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 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)
Expand Down
30 changes: 9 additions & 21 deletions Reqnroll/Bindings/BindingScope.cs
Original file line number Diff line number Diff line change
@@ -1,38 +1,26 @@
using System;
using System.Linq;
using Cucumber.TagExpressions;

namespace Reqnroll.Bindings
{
public class BindingScope
public class BindingScope(ITagExpression tagExpression, string featureTitle, string scenarioTitle)
{
public string Tag { get; private set; }
public string FeatureTitle { get; private set; }
public string ScenarioTitle { get; private set; }
public string Tag => tagExpression.ToString();
Comment thread
gasparnagy marked this conversation as resolved.
Outdated

public BindingScope(string tag, string featureTitle, string scenarioTitle)
{
Tag = RemoveLeadingAt(tag);
FeatureTitle = featureTitle;
ScenarioTitle = scenarioTitle;
}
public string FeatureTitle { get; } = featureTitle;

private string RemoveLeadingAt(string tag)
{
if (tag == null || !tag.StartsWith("@"))
return tag;

return tag.Substring(1); // remove leading "@"
}
public string ScenarioTitle { get; } = scenarioTitle;

public bool Match(StepContext stepContext, out int scopeMatches)
{
scopeMatches = 0;

var tags = stepContext.Tags;

if (Tag != null)
{
if (!tags.Contains(Tag))
var tags = stepContext.Tags.Select(t => "@" + t).ToList();

if (!tagExpression.Evaluate(tags))
return false;

scopeMatches++;
Expand Down Expand Up @@ -64,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);
}

Expand Down
9 changes: 6 additions & 3 deletions Reqnroll/Bindings/Discovery/BindingSourceProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IStepDefinitionBindingBuilder> _stepDefinitionBindingBuilders = new();

protected BindingSourceProcessor(IBindingFactory bindingFactory)
protected BindingSourceProcessor(IBindingFactory bindingFactory, ITagExpressionParser tagExpressionParser)
{
_bindingFactory = bindingFactory;
_tagExpressionParser = tagExpressionParser;
}

public bool CanProcessTypeAttribute(string attributeTypeName)
Expand Down Expand Up @@ -75,7 +78,7 @@ public virtual void BuildingCompleted()
private IEnumerable<BindingScope> GetScopes(IEnumerable<BindingSourceAttribute> attributes)
{
return attributes.Where(attr => attr.AttributeType.TypeEquals(typeof(ScopeAttribute)))
.Select(attr => new BindingScope(attr.TryGetAttributeValue<string>("Tag"), attr.TryGetAttributeValue<string>("Feature"), attr.TryGetAttributeValue<string>("Scenario")));
.Select(attr => new BindingScope(_tagExpressionParser.Parse(attr.TryGetAttributeValue<string>("Tag")), attr.TryGetAttributeValue<string>("Feature"), attr.TryGetAttributeValue<string>("Scenario")));
Comment thread
gasparnagy marked this conversation as resolved.
Comment thread
gasparnagy marked this conversation as resolved.
}

private bool IsBindingType(BindingSourceType bindingSourceType)
Expand Down Expand Up @@ -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));
Expand Down
33 changes: 33 additions & 0 deletions Reqnroll/Bindings/Discovery/ReqnrollTagExpressionParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Cucumber.TagExpressions;
using System;

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;
}
}
3 changes: 2 additions & 1 deletion Reqnroll/Bindings/Discovery/RuntimeBindingSourceProcessor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Reqnroll.Tracing;
using Cucumber.TagExpressions;

namespace Reqnroll.Bindings.Discovery
{
Expand All @@ -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;
Expand Down
9 changes: 4 additions & 5 deletions Reqnroll/Bindings/Provider/BindingProviderService.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using Reqnroll.Bindings.Discovery;
using Cucumber.TagExpressions;
using Reqnroll.Bindings.Discovery;
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;
Expand Down Expand Up @@ -138,9 +138,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
};
Expand Down Expand Up @@ -188,6 +186,7 @@ public override void RegisterGlobalContainerDefaults(ObjectContainer container)
base.RegisterGlobalContainerDefaults(container);
container.RegisterTypeAs<DryRunBindingInvoker, IAsyncBindingInvoker>();
container.RegisterTypeAs<Formatters.Configuration.FormattersForcedDisabledOverrideProvider, IFormattersConfigurationDisableOverrideProvider>();
var _ = container.RegisterFactoryAs<ITagExpressionParser>(() => new ReqnrollTagExpressionParser(new TagExpressionParser())).InstancePerDependency;
Comment thread
gasparnagy marked this conversation as resolved.
Outdated
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion Reqnroll/Infrastructure/DefaultDependencyProvider.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Cucumber.TagExpressions;
using Gherkin.CucumberMessages;
using Reqnroll.Analytics;
using Reqnroll.Analytics.AppInsights;
Expand Down Expand Up @@ -27,7 +28,6 @@
using Reqnroll.Time;
using Reqnroll.Tracing;
using Reqnroll.Utils;
using System;

namespace Reqnroll.Infrastructure
{
Expand Down Expand Up @@ -135,6 +135,8 @@ public virtual void RegisterGlobalContainerDefaults(ObjectContainer container)
container.RegisterTypeAs<TestCaseExecutionTrackerFactory, ITestCaseExecutionTrackerFactory>();
container.RegisterFactoryAs<IMessagePublisher>(() => container.Resolve<ICucumberMessageBroker>());
container.RegisterTypeAs<StepTrackerFactory, IStepTrackerFactory>();

var _ = container.RegisterFactoryAs<ITagExpressionParser>(() => new ReqnrollTagExpressionParser(new TagExpressionParser())).InstancePerDependency;
}

public virtual void RegisterTestThreadContainerDefaults(ObjectContainer testThreadContainer)
Expand Down
1 change: 1 addition & 0 deletions Reqnroll/Reqnroll.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Cucumber.TagExpressions" Version="8.1.0" />
<PackageReference Include="Gherkin" Version="35.0.0" />
<PackageReference Include="Cucumber.CucumberExpressions" Version="17.1.0" />
<PackageReference Include="Cucumber.Messages" Version="30.1.0" />
Expand Down
48 changes: 22 additions & 26 deletions Tests/Reqnroll.Formatters.Tests/CucumberMessagesValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -139,10 +137,10 @@ private void CompareMessageType<T>(int partitionNumber)
if (!_expectedElementsByType.ContainsKey(typeof(T)))
return;

int actualsPartitionNumber = MapPartitionNumber(partitionNumber);
int actualPartitionNumber = MapPartitionNumber(partitionNumber);

var actual = _actualElementsByType.TryGetValue(typeof(T), out HashSet<object>? actualElements) && actualElements.Count > 0 ?
actualElements.OfType<T>().Where(e => _actualPartitions[e!] == actualsPartitionNumber).ToList() : new List<T>();
actualElements.OfType<T>().Where(e => _actualPartitions[e!] == actualPartitionNumber).ToList() : new List<T>();

var expected = _expectedElementsByType[typeof(T)].AsEnumerable().OfType<T>().Where(e => _expectedPartitions[e!] == partitionNumber).ToList();

Expand Down Expand Up @@ -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<TestStepStarted>().Where(e => _actualPartitions[e!] == actualsPartitionNumber).Select(tss => tss.TestStepId).ToList();
var expectedTestStepStartedTestStepIds = _expectedElementsByType[typeof(TestStepStarted)].OfType<TestStepStarted>().Where(e => _expectedPartitions[e!] == partitionNumber).Select(tss => tss.TestStepId).ToList();
var actualTestStepStartedTestStepIds = _actualElementsByType[typeof(TestStepStarted)].OfType<TestStepStarted>().Where(e => _actualPartitions[e] == actualPartitionNumber).Select(tss => tss.TestStepId).ToList();
var expectedTestStepStartedTestStepIds = _expectedElementsByType[typeof(TestStepStarted)].OfType<TestStepStarted>().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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<Group> groups)
private bool GroupListIsEmpty(List<Group>? groups)
{
if (groups == null || groups.Count == 0) return true;
foreach (var group in groups)
Expand All @@ -407,7 +405,7 @@ private EquivalencyAssertionOptions<T> ArrangeFluentAssertionOptions<T>(Equivale
.ComparingByMembers<Ci>()
.ComparingByMembers<Comment>()
.ComparingByMembers<Io.Cucumber.Messages.Types.DataTable>()
.ComparingByMembers<Io.Cucumber.Messages.Types.DocString>()
.ComparingByMembers<DocString>()
.ComparingByMembers<Envelope>()
.ComparingByMembers<Examples>()
.ComparingByMembers<Feature>()
Expand Down Expand Up @@ -458,15 +456,15 @@ private EquivalencyAssertionOptions<T> ArrangeFluentAssertionOptions<T>(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<string>(ctx =>
//{
// var actual = ctx.Subject.Split("-")[0];
// var expected = ctx.Expectation.Split("-")[0];
// 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<Suggestion>(ctx =>
{
Expand Down Expand Up @@ -547,17 +545,15 @@ private EquivalencyAssertionOptions<T> ArrangeFluentAssertionOptions<T>(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<List<Hook>>()
Expand Down
3 changes: 2 additions & 1 deletion Tests/Reqnroll.RuntimeTests/BindingSourceProcessorStub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Reqnroll.Bindings.CucumberExpressions;
using Reqnroll.Bindings.Discovery;
using Reqnroll.Configuration;
using Cucumber.TagExpressions;

namespace Reqnroll.RuntimeTests
{
Expand All @@ -17,7 +18,7 @@ public class BindingSourceProcessorStub : BindingSourceProcessor, IRuntimeBindin

public IEnumerable<string> 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()))
{
}

Expand Down
Loading
Loading