Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# [vNext]

## Improvements:
* Elimination of the PickleIndex argument from the row test method signature and row test attributes. This restores the method signature to what is was in V2.x. (#938)

## Bug fixes:

*Contributors of this release (in alphabetical order):*
*Contributors of this release (in alphabetical order):* @clrudolphi

# v3.3.0 - 2025-12-17

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ public void SetTestMethodIgnore(TestClassGenerationContext generationContext, Co
public void SetRowTest(TestClassGenerationContext generationContext, CodeMemberMethod testMethod, string scenarioTitle)
{
// For a row test, mark it as a test with a display name.
var paramNames = string.Join(", ", testMethod.Parameters.Cast<CodeParameterDeclarationExpression>().Take(testMethod.Parameters.Count - 2).Select(x => $"${x.Name}"));
var paramNames = string.Join(", ", testMethod.Parameters.Cast<CodeParameterDeclarationExpression>().Take(testMethod.Parameters.Count - 1).Select(x => $"${x.Name}"));
SetTestMethod(generationContext, testMethod, scenarioTitle + " (" + paramNames + ")");
}

Expand Down
13 changes: 0 additions & 13 deletions Reqnroll.Generator/Generation/ScenarioPartHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,18 +233,5 @@ private CodeExpression GetSubstitutedString(string text, ParameterSubstitution p
"Format",
formatArguments.ToArray());
}
public void AddVariableForPickleIndex(CodeMemberMethod testMethod, bool pickleIdIncludedInParameters, int? pickleIndex)
{
if (!pickleIdIncludedInParameters && pickleIndex == null)
throw new ArgumentNullException(nameof(pickleIndex));

// string pickleId = "<pickleJar.CurrentPickleId>"; or
// string pickleId = __pickleId;
var pickleIdVariable = new CodeVariableDeclarationStatement(typeof(string), GeneratorConstants.PICKLEINDEX_VARIABLE_NAME,
pickleIdIncludedInParameters ?
new CodeVariableReferenceExpression(GeneratorConstants.PICKLEINDEX_PARAMETER_NAME) :
new CodePrimitiveExpression(pickleIndex.Value.ToString()));
testMethod.Statements.Add(pickleIdVariable);
}
}
}
15 changes: 12 additions & 3 deletions Reqnroll.Generator/Generation/UnitTestFeatureGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public UnitTestFeatureGenerationResult GenerateUnitTestFixture(ReqnrollDocument

_unitTestMethodGenerator.CreateUnitTests(feature, generationContext);

SerializeFeatureEnvelopes(generationContext);
//before returning the generated code, call the provider's method in case the generated code needs to be customized
_testGeneratorProvider.FinalizeTestClass(generationContext);

Expand Down Expand Up @@ -253,10 +254,8 @@ string GetFeatureMessagesResourceName()
Envelope.Create(featureGherkinDocumentMessage)
};
envelopes.AddRange(featurePickleMessages.Select(Envelope.Create));
generationContext.FeatureEnvelopes = envelopes;
envelopeCount = envelopes.Count;

// Serialize each envelope and append into a ndjson format
generationContext.FeatureMessages = string.Join(Environment.NewLine, envelopes.Select(NdjsonSerializer.Serialize));
}
catch (System.Exception e)
{
Expand All @@ -279,6 +278,16 @@ string GetFeatureMessagesResourceName()
featureLevelCucumberMessagesExpression));
}

private void SerializeFeatureEnvelopes(TestClassGenerationContext generationContext)
{
if (generationContext.FeatureEnvelopes == null || !generationContext.FeatureEnvelopes.Any() || string.IsNullOrEmpty(generationContext.FeatureMessagesResourceName))
{
return;
}
// Serialize each envelope and append into a ndjson format
generationContext.FeatureMessages = string.Join(Environment.NewLine, generationContext.FeatureEnvelopes.Select(NdjsonSerializer.Serialize));
}

private void SetupTestClassInitializeMethod(TestClassGenerationContext generationContext)
{
var testClassInitializeMethod = generationContext.TestClassInitializeMethod;
Expand Down
55 changes: 47 additions & 8 deletions Reqnroll.Generator/Generation/UnitTestMethodGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Linq;
using Gherkin.Ast;
using Reqnroll.Configuration;
using Reqnroll.Formatters.RuntimeSupport;
using Reqnroll.Generator.CodeDom;
using Reqnroll.Generator.UnitTestConverter;
using Reqnroll.Generator.UnitTestProvider;
Expand Down Expand Up @@ -211,7 +212,7 @@ private void GenerateTestBody(
// Cucumber Messages support uses a new variables: pickleIndex
// The pickleIndex tells the runtime which Pickle this test corresponds to.
// When Backgrounds and Rule Backgrounds are used, we don't know ahead of time how many Steps there are in the Pickle.
AddVariableForPickleIndex(testMethod, pickleIdIncludedInParameters, pickleIndex);
AddVariableForPickleIndex(testMethod, pickleIdIncludedInParameters, pickleIndex, feature.Name, scenarioDefinition.Name, additionalTagsExpression, new CodeVariableReferenceExpression(GeneratorConstants.SCENARIO_ARGUMENTS_VARIABLE_NAME));

testMethod.Statements.Add(
new CodeVariableDeclarationStatement(new CodeTypeReference(typeof(ScenarioInfo), CodeTypeReferenceOptions.GlobalReference), "scenarioInfo",
Expand Down Expand Up @@ -242,9 +243,39 @@ private void GenerateTestBody(
GenerateScenarioCleanupMethodCall(generationContext, testMethod);
}

internal void AddVariableForPickleIndex(CodeMemberMethod testMethod, bool pickleIdIncludedInParameters, int? pickleIndex)
internal void AddVariableForPickleIndex(CodeMemberMethod testMethod, bool pickleIdIncludedInParameters, int? pickleIndex, string featureName, string scenarioName, CodeExpression exampleTags, CodeVariableReferenceExpression arguments)
{
_scenarioPartHelper.AddVariableForPickleIndex(testMethod, pickleIdIncludedInParameters, pickleIndex);
if (!pickleIdIncludedInParameters && pickleIndex == null)
throw new ArgumentNullException(nameof(pickleIndex));

// if simple scenario, just assign the pickle index value directly
// string pickleIndex = "<pickleIndex>";

// if scenario outline with row tests, we generate code that invokes the TestRowPickleMapper to get the pickle index from the row hash.

CodeExpression valueExpression;
if (!pickleIdIncludedInParameters)
{
valueExpression = new CodePrimitiveExpression(pickleIndex.Value.ToString());
}
else
{
// string pickleIndex = global::TestRowPickleMapper.GetPickleIndexFromTestRow(featureInfo, featureName, scenarioName, exampleTags, arguments.Values);
var testRowPickleMapperTypeRef = new CodeTypeReferenceExpression(new CodeTypeReference(typeof(TestRowPickleMapper), CodeTypeReferenceOptions.GlobalReference));
valueExpression = new CodeMethodInvokeExpression(
testRowPickleMapperTypeRef,
nameof(TestRowPickleMapper.GetPickleIndexFromTestRow),
new CodeVariableReferenceExpression(GeneratorConstants.FEATUREINFO_FIELD),
new CodePrimitiveExpression(featureName),
new CodePrimitiveExpression(scenarioName),
exampleTags,
new CodePropertyReferenceExpression(arguments, "Values"));
}

var pickleIdVariable = new CodeVariableDeclarationStatement(typeof(string), GeneratorConstants.PICKLEINDEX_VARIABLE_NAME,
valueExpression);

testMethod.Statements.Add(pickleIdVariable);
}

private void AddVariableForTags(CodeMemberMethod testMethod, CodeExpression tagsExpression)
Expand Down Expand Up @@ -383,7 +414,7 @@ private CodeMethodInvokeExpression CreateTestRunnerSkipScenarioCall()
private void GenerateScenarioOutlineExamplesAsIndividualMethods(
TestClassGenerationContext generationContext,
ScenarioDefinitionInFeatureFile scenarioDefinitionInFeature,
CodeMemberMethod scenarioOutlineTestMethod,
CodeMemberMethod scenarioOutlineTestMethod,
ParameterSubstitution paramToIdentifier,
ref int pickleIndex)
{
Expand Down Expand Up @@ -432,10 +463,20 @@ private void GenerateScenarioOutlineExamplesAsRowTests(TestClassGenerationContex
{
foreach (var row in examples.TableBody)
{
var arguments = row.Cells.Select(c => c.Value).Concat([pickleIndex.ToString()]);

var arguments = row.Cells.Select(c => c.Value).ToArray();
_unitTestGeneratorProvider.SetRow(generationContext, scenarioOutlineTestMethod, arguments, GetNonIgnoreTags(examples.Tags), HasIgnoreTag(examples.Tags));

// hash the feature name, scenario outline name, example set tags and row values to create a unique id for the row.
// Look up the pickle in the generation context Feature Envelopes using the pickleIndex, and add the hash to the Pickle's list of tags.
var featureName = generationContext.Feature.Name;
var scenarioOutlineName = scenarioOutline.Name;
var pickle = generationContext.FeatureEnvelopes
.Where(e => e.Pickle != null)
.Select(e => e.Pickle)
.ElementAt(pickleIndex);

TestRowPickleMapper.MarkPickleWithRowHash(pickle, featureName, scenarioOutlineName, examples.Tags.Select(t => t.Name), arguments);

pickleIndex++;
}
}
Expand Down Expand Up @@ -497,7 +538,6 @@ private CodeMemberMethod CreateScenarioOutlineTestMethod(TestClassGenerationCont
{
testMethod.Parameters.Add(new CodeParameterDeclarationExpression(typeof(string), pair.Value));
}
testMethod.Parameters.Add(new CodeParameterDeclarationExpression(typeof(string), GeneratorConstants.PICKLEINDEX_PARAMETER_NAME));
testMethod.Parameters.Add(new CodeParameterDeclarationExpression(typeof(string[]), GeneratorConstants.SCENARIO_OUTLINE_EXAMPLE_TAGS_PARAMETER));
return testMethod;
}
Expand All @@ -519,7 +559,6 @@ private void GenerateScenarioOutlineTestVariant(

//call test implementation with the params
var argumentExpressions = row.Cells.Select(paramCell => new CodePrimitiveExpression(paramCell.Value)).Cast<CodeExpression>().ToList();
argumentExpressions.Add(new CodePrimitiveExpression(pickleIndex.ToString()));
argumentExpressions.Add(_scenarioPartHelper.GetStringArrayExpression(exampleSetTags));

var statements = new List<CodeStatement>();
Expand Down
2 changes: 2 additions & 0 deletions Reqnroll.Generator/TestClassGenerationContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.CodeDom;
using System.Collections.Generic;
using Io.Cucumber.Messages.Types;
using Reqnroll.Generator.Interfaces;
using Reqnroll.Generator.UnitTestProvider;
using Reqnroll.Parser;
Expand Down Expand Up @@ -36,6 +37,7 @@ public class TestClassGenerationContext

public string FeatureMessagesResourceName { get; set; }
internal string FeatureMessages { get; set; }
internal IEnumerable<Envelope> FeatureEnvelopes { get; set; }

public FeatureFileInput FeatureFileInput { get; set; }

Expand Down
4 changes: 2 additions & 2 deletions Reqnroll/FeatureInfo.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using Reqnroll.Formatters.RuntimeSupport;
using Reqnroll.Tracing;
using System;
using System.Diagnostics;
using System.Globalization;
using Reqnroll.Formatters.RuntimeSupport;
using Reqnroll.Tracing;

namespace Reqnroll
{
Expand Down
19 changes: 19 additions & 0 deletions Reqnroll/Formatters/ExecutionTracking/FeatureExecutionTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ public async Task ProcessEvent(FeatureStartedEvent featureStartedEvent)
{
var pickle = featurePickles[i];
PickleIds.Add(i.ToString(), pickle.Id);

// If the pickle has a tag that begins with "@__RowHash_", we need to replace the pickle with a new one without that tag
var tagsToRemovePrefix = TestRowPickleMapper.RowHashTagPrefix;
if (pickle.Tags != null && pickle.Tags.Any(t => t.Name.StartsWith(tagsToRemovePrefix, StringComparison.Ordinal)))
{
// Create a new Pickle without the "@RowHash" tag(s)
var filteredTags = pickle.Tags.Where(t => !t.Name.StartsWith(tagsToRemovePrefix, StringComparison.Ordinal)).ToList();
var newPickle = new Pickle(
id: pickle.Id,
uri: pickle.Uri,
name: pickle.Name,
language: pickle.Language,
steps: pickle.Steps,
tags: filteredTags,
astNodeIds: pickle.AstNodeIds
);
pickle = newPickle;
}

await _publisher.PublishAsync(Envelope.Create(pickle));
}
PickleJar = new PickleJar(featurePickles);
Expand Down
79 changes: 74 additions & 5 deletions Reqnroll/Formatters/RuntimeSupport/FeatureLevelCucumberMessages.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Io.Cucumber.Messages.Types;
using Reqnroll.Formatters.PayloadProcessing;
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
Expand All @@ -17,24 +19,26 @@ public class FeatureLevelCucumberMessages : IFeatureLevelCucumberMessages
private readonly Lazy<IReadOnlyCollection<Envelope>> _embeddedEnvelopes;
private Lazy<Source> _source;
private Lazy<GherkinDocument> _gherkinDocument;
private Lazy<IEnumerable<Pickle>> _pickles;
private Lazy<(IEnumerable<Pickle> Pickles, ConcurrentDictionary<string, (int[] PickleIndices, int NextToBeUsed)> Map)> _pickleDetails;

internal int ExpectedEnvelopeCount { get; }

private ConcurrentDictionary<string, (int[] PickleIndices, int NextToBeUsed)> RowHashToPickleIndexMap => _pickleDetails.Value.Map;

public FeatureLevelCucumberMessages(string featureMessagesResourceName, int envelopeCount)
{
var assembly = Assembly.GetCallingAssembly();

var isEnabled = !string.IsNullOrEmpty(featureMessagesResourceName) && envelopeCount > 0;
_embeddedEnvelopes = new Lazy<IReadOnlyCollection<Envelope>>(() =>
_embeddedEnvelopes = new Lazy<IReadOnlyCollection<Envelope>>(() =>
isEnabled ? ReadEnvelopesFromAssembly(assembly, featureMessagesResourceName) : []);
ExpectedEnvelopeCount = envelopeCount;

InitializeLazyProperties();
}

// Internal constructor for testing with direct stream access
internal FeatureLevelCucumberMessages(Stream stream, string resourceNameOfEmbeddedMessages, int envelopeCount)
internal FeatureLevelCucumberMessages(Stream stream, int envelopeCount)
{
_embeddedEnvelopes = new Lazy<IReadOnlyCollection<Envelope>>(() => ReadEnvelopesFromStream(stream));
ExpectedEnvelopeCount = envelopeCount;
Expand All @@ -50,11 +54,38 @@ internal FeatureLevelCucumberMessages(IReadOnlyCollection<Envelope> envelopes, i

InitializeLazyProperties();
}

private void InitializeLazyProperties()
{
_source = new Lazy<Source>(() => _embeddedEnvelopes.Value.Select(e => e.Source).DefaultIfEmpty(null).FirstOrDefault(s => s != null));
_gherkinDocument = new Lazy<GherkinDocument>(() => _embeddedEnvelopes.Value.Select(e => e.GherkinDocument).DefaultIfEmpty(null).FirstOrDefault(g => g != null));
_pickles = new Lazy<IEnumerable<Pickle>>(() => _embeddedEnvelopes.Value.Select(e => e.Pickle).Where(p => p != null));

(Pickle[], ConcurrentDictionary<string, (int[], int)>) CalculatePickleDetails()
{
var pickles = _embeddedEnvelopes.Value.Select(e => e.Pickle).Where(p => p != null).ToArray();

var hashToIndexList = new List<(string Hash, int PickleIndex)>();

for (int pickleIndex = 0; pickleIndex < pickles.Length; pickleIndex++)
{
var pickle = pickles[pickleIndex];
if (TestRowPickleMapper.PickleHasRowHashMarkerTag(pickle, out var rowHash))
{
TestRowPickleMapper.RemoveHashRowMarkerTag(pickle);
hashToIndexList.Add((rowHash, pickleIndex));
}
}

// initialize the map with the indices per hash and set the next to be used to 0
var map = new ConcurrentDictionary<string, (int[] PickleIndices, int NextToBeUsed)>(
hashToIndexList
.GroupBy(e => e.Hash)
.Select(g => new KeyValuePair<string, (int[] PickleIndices, int NextToBeUsed)>(g.Key, (g.Select(e => e.PickleIndex).ToArray(), 0))));

return (pickles, map);
}

_pickleDetails = new(() => CalculatePickleDetails());
}

internal IReadOnlyCollection<Envelope> ReadEnvelopesFromAssembly(Assembly assembly, string featureMessagesResourceName)
Expand Down Expand Up @@ -107,5 +138,43 @@ public bool HasMessages

public Source Source => _source.Value;
public GherkinDocument GherkinDocument => _gherkinDocument.Value;
public IEnumerable<Pickle> Pickles => _pickles.Value;
public IEnumerable<Pickle> Pickles => _pickleDetails.Value.Pickles;

public string GetPickleIndexFromTestRow(string featureName, string scenarioOutlineName, IEnumerable<string> tags, ICollection rowValues)
{
var rowValuesStrings = rowValues.Cast<object>().Select(v => v?.ToString() ?? string.Empty);

var rowHash = TestRowPickleMapper.ComputeHash(featureName, scenarioOutlineName, tags, rowValuesStrings);

// The _rowHashToPickleIndexMaps dictionary maps row hashes to a tuple of (List of pickle indices, current use index).
// We will apply a round-robin through the list of pickle indices for each row hash as they are requested.

// Thread-safe update of useIndex using a loop and TryUpdate
if (RowHashToPickleIndexMap.TryGetValue(rowHash, out var hashUseTracker))
{
// returns the pickle index to use and calculates an updated hashUseTracker for the next use
int AcquirePickleIndex(out (int[] PickleIndices, int NextToBeUsed) nextHashUseTracker)
{
var result = hashUseTracker.PickleIndices[hashUseTracker.NextToBeUsed];
nextHashUseTracker = (hashUseTracker.PickleIndices, (hashUseTracker.NextToBeUsed + 1) % hashUseTracker.PickleIndices.Length);
return result;
}

var pickleIndex = AcquirePickleIndex(out var newHashUseTracker);

// Try to update the useIndex atomically
while (!RowHashToPickleIndexMap.TryUpdate(rowHash, newHashUseTracker, hashUseTracker))
{
// If update failed, reload and retry
if (!RowHashToPickleIndexMap.TryGetValue(rowHash, out hashUseTracker))
break;

pickleIndex = AcquirePickleIndex(out newHashUseTracker);
}

return pickleIndex.ToString();
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Io.Cucumber.Messages.Types;
using System.Collections;
using System.Collections.Generic;

namespace Reqnroll.Formatters.RuntimeSupport;
Expand All @@ -9,4 +10,6 @@ public interface IFeatureLevelCucumberMessages
GherkinDocument GherkinDocument { get; }
IEnumerable<Pickle> Pickles { get; }
Source Source { get; }

string GetPickleIndexFromTestRow(string featureName, string scenarioOutlineName, IEnumerable<string> tags, ICollection rowValues);
}
Loading