From bad7b5239f30d8ce08a7c62b67a5dd2c4da9f5c3 Mon Sep 17 00:00:00 2001
From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com>
Date: Tue, 3 Feb 2026 10:58:10 -0600
Subject: [PATCH 1/5] Added Formatters element to the JsonConfig structure.
Using JsonConfig for deserializing the reqnroll.config content to obtain the
Formatters configuration.
---
.../JsonConfig/FormatterOptionsElement.cs | 18 ++
.../JsonConfig/FormattersElement.cs | 21 ++
.../Configuration/JsonConfig/JsonConfig.cs | 3 +
.../JsonConfigurationSourceGenerator.cs | 2 +
.../FileBasedConfigurationResolver.cs | 36 +--
.../FormattersConfigExtractor.cs | 122 +++++++++
.../FormattersConfigurationResolverBase.cs | 63 -----
.../JsonEnvironmentConfigurationResolver.cs | 13 +-
.../Configuration/JsonConfigTests.cs | 111 ++++++++
.../FormattersConfigExtractorTests.cs | 248 ++++++++++++++++++
10 files changed, 551 insertions(+), 86 deletions(-)
create mode 100644 Reqnroll/Configuration/JsonConfig/FormatterOptionsElement.cs
create mode 100644 Reqnroll/Configuration/JsonConfig/FormattersElement.cs
create mode 100644 Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
delete mode 100644 Reqnroll/Formatters/Configuration/FormattersConfigurationResolverBase.cs
create mode 100644 Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs
diff --git a/Reqnroll/Configuration/JsonConfig/FormatterOptionsElement.cs b/Reqnroll/Configuration/JsonConfig/FormatterOptionsElement.cs
new file mode 100644
index 000000000..5f6e03bb4
--- /dev/null
+++ b/Reqnroll/Configuration/JsonConfig/FormatterOptionsElement.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Reqnroll.Configuration.JsonConfig
+{
+ public class FormatterOptionsElement
+ {
+ [JsonPropertyName("outputFilePath")]
+ public string OutputFilePath { get; set; }
+
+ ///
+ /// Captures any additional options not explicitly defined above.
+ ///
+ [JsonExtensionData]
+ public Dictionary AdditionalOptions { get; set; }
+ }
+}
diff --git a/Reqnroll/Configuration/JsonConfig/FormattersElement.cs b/Reqnroll/Configuration/JsonConfig/FormattersElement.cs
new file mode 100644
index 000000000..d1db75c64
--- /dev/null
+++ b/Reqnroll/Configuration/JsonConfig/FormattersElement.cs
@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Reqnroll.Configuration.JsonConfig
+{
+ public class FormattersElement
+ {
+ [JsonPropertyName("html")]
+ public FormatterOptionsElement Html { get; set; }
+
+ [JsonPropertyName("message")]
+ public FormatterOptionsElement Message { get; set; }
+
+ ///
+ /// Captures any additional/custom formatters not explicitly defined above.
+ ///
+ [JsonExtensionData]
+ public Dictionary AdditionalFormatters { get; set; }
+ }
+}
diff --git a/Reqnroll/Configuration/JsonConfig/JsonConfig.cs b/Reqnroll/Configuration/JsonConfig/JsonConfig.cs
index 7b29d1a13..990f22241 100644
--- a/Reqnroll/Configuration/JsonConfig/JsonConfig.cs
+++ b/Reqnroll/Configuration/JsonConfig/JsonConfig.cs
@@ -27,5 +27,8 @@ public class JsonConfig
[JsonPropertyName("bindingAssemblies")]
public List BindingAssemblies { get; set; }
+
+ [JsonPropertyName("formatters")]
+ public FormattersElement Formatters { get; set; }
}
}
\ No newline at end of file
diff --git a/Reqnroll/Configuration/JsonConfig/JsonConfigurationSourceGenerator.cs b/Reqnroll/Configuration/JsonConfig/JsonConfigurationSourceGenerator.cs
index 944cd6475..9f41b355e 100644
--- a/Reqnroll/Configuration/JsonConfig/JsonConfigurationSourceGenerator.cs
+++ b/Reqnroll/Configuration/JsonConfig/JsonConfigurationSourceGenerator.cs
@@ -10,6 +10,8 @@ namespace Reqnroll.Configuration.JsonConfig;
Converters = [typeof(CustomTimeSpanConverter)],
ReadCommentHandling = JsonCommentHandling.Skip)] // the user can comment his used configuration value
[JsonSerializable(typeof(JsonConfig))]
+[JsonSerializable(typeof(FormattersElement))]
+[JsonSerializable(typeof(FormatterOptionsElement))]
internal partial class JsonConfigurationSourceGenerator : JsonSerializerContext
{
diff --git a/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
index f008c707a..95aff788a 100644
--- a/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
+++ b/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
@@ -3,11 +3,12 @@
using Reqnroll.Formatters.RuntimeSupport;
using Reqnroll.Utils;
using System;
+using System.Collections.Generic;
using System.Text.Json;
namespace Reqnroll.Formatters.Configuration;
-public class FileBasedConfigurationResolver : FormattersConfigurationResolverBase, IFileBasedConfigurationResolver
+public class FileBasedConfigurationResolver : IFileBasedConfigurationResolver
{
private readonly IReqnrollJsonLocator _configFileLocator;
private readonly IFileSystem _fileSystem;
@@ -26,7 +27,24 @@ public FileBasedConfigurationResolver(
_log = log;
}
- protected override JsonDocument GetJsonDocument()
+ public IDictionary> Resolve()
+ {
+ var jsonContent = GetJsonContent();
+ if (jsonContent == null)
+ return new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ try
+ {
+ return FormattersConfigExtractor.ExtractFormatters(jsonContent);
+ }
+ catch (JsonException ex)
+ {
+ _log?.WriteMessage($"Failed to parse formatters configuration: {ex.Message}");
+ return new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ }
+ }
+
+ private string GetJsonContent()
{
try
{
@@ -71,19 +89,7 @@ protected override JsonDocument GetJsonDocument()
return null;
}
- try
- {
- return JsonDocument.Parse(jsonFileContent, new JsonDocumentOptions
- {
- CommentHandling = JsonCommentHandling.Skip,
- AllowTrailingCommas = true // More lenient parsing
- });
- }
- catch (JsonException ex)
- {
- _log?.WriteMessage($"Failed to parse JSON from file '{fileName}': {ex.Message}");
- return null;
- }
+ return jsonFileContent;
}
catch (Exception ex)
{
diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs b/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
new file mode 100644
index 000000000..92ecbebf1
--- /dev/null
+++ b/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
@@ -0,0 +1,122 @@
+using Reqnroll.Configuration.JsonConfig;
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+
+namespace Reqnroll.Formatters.Configuration;
+
+///
+/// Utility class for extracting formatters configuration from JSON content
+/// using the centralized JsonConfig deserialization.
+///
+public static class FormattersConfigExtractor
+{
+ ///
+ /// Deserializes JSON content and extracts the formatters configuration as a dictionary.
+ ///
+ /// The JSON content to parse.
+ /// A dictionary of formatter configurations, or an empty dictionary if parsing fails or no formatters are defined.
+ public static IDictionary> ExtractFormatters(string jsonContent)
+ {
+ if (string.IsNullOrWhiteSpace(jsonContent))
+ return new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ try
+ {
+ var jsonConfig = JsonSerializer.Deserialize(jsonContent, JsonConfigurationSourceGenerator.Default.JsonConfig);
+ return ConvertFormattersElement(jsonConfig?.Formatters);
+ }
+ catch (JsonException)
+ {
+ return new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ }
+ }
+
+ ///
+ /// Converts a FormattersElement to the dictionary format used by the formatters configuration system.
+ ///
+ /// The FormattersElement to convert.
+ /// A dictionary of formatter configurations.
+ public static IDictionary> ConvertFormattersElement(FormattersElement formatters)
+ {
+ var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ if (formatters == null)
+ return result;
+
+ // Process known formatters
+ if (formatters.Html != null)
+ {
+ result["html"] = ConvertFormatterOptions(formatters.Html);
+ }
+
+ if (formatters.Message != null)
+ {
+ result["message"] = ConvertFormatterOptions(formatters.Message);
+ }
+
+ // Process additional/custom formatters captured by JsonExtensionData
+ if (formatters.AdditionalFormatters != null)
+ {
+ foreach (var kvp in formatters.AdditionalFormatters)
+ {
+ if (kvp.Value.ValueKind == JsonValueKind.Object)
+ {
+ var configValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var property in kvp.Value.EnumerateObject())
+ {
+ configValues[property.Name] = GetConfigValue(property.Value);
+ }
+ result[kvp.Key] = configValues;
+ }
+ else
+ {
+ // Non-object values get an empty config dictionary
+ result[kvp.Key] = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ private static IDictionary ConvertFormatterOptions(FormatterOptionsElement options)
+ {
+ var configValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (options.OutputFilePath != null)
+ {
+ configValues["outputFilePath"] = options.OutputFilePath;
+ }
+
+ // Process additional options captured by JsonExtensionData
+ if (options.AdditionalOptions != null)
+ {
+ foreach (var kvp in options.AdditionalOptions)
+ {
+ configValues[kvp.Key] = GetConfigValue(kvp.Value);
+ }
+ }
+
+ return configValues;
+ }
+
+ private static object GetConfigValue(JsonElement valueElement)
+ {
+ switch (valueElement.ValueKind)
+ {
+ case JsonValueKind.String:
+ return valueElement.GetString();
+ case JsonValueKind.False:
+ case JsonValueKind.True:
+ return valueElement.GetBoolean();
+ case JsonValueKind.Number:
+ return valueElement.GetDouble();
+ case JsonValueKind.Null:
+ return null;
+ default:
+ // For embedded JSON objects or arrays, keep as JsonElement
+ return valueElement;
+ }
+ }
+}
diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigurationResolverBase.cs b/Reqnroll/Formatters/Configuration/FormattersConfigurationResolverBase.cs
deleted file mode 100644
index aca957736..000000000
--- a/Reqnroll/Formatters/Configuration/FormattersConfigurationResolverBase.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Text.Json;
-
-namespace Reqnroll.Formatters.Configuration;
-
-public abstract class FormattersConfigurationResolverBase : IFormattersConfigurationResolverBase
-{
- protected const string FORMATTERS_KEY = "formatters";
-
- public IDictionary> Resolve()
- {
- var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
- JsonDocument jsonDocument = GetJsonDocument();
-
- if (jsonDocument != null)
- {
- ProcessJsonDocument(jsonDocument, result);
- }
-
- return result;
- }
-
- protected abstract JsonDocument GetJsonDocument();
-
- protected virtual void ProcessJsonDocument(JsonDocument jsonDocument, Dictionary> result)
- {
- if (jsonDocument.RootElement.TryGetProperty(FORMATTERS_KEY, out JsonElement formatters))
- {
- foreach(JsonProperty formatterProperty in formatters.EnumerateObject())
- {
- var configValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- if (formatterProperty.Value.ValueKind == JsonValueKind.Object)
- {
- foreach (JsonProperty configProperty in formatterProperty.Value.EnumerateObject())
- {
- configValues.Add(configProperty.Name, GetConfigValue(configProperty.Value));
- }
- }
-
- result.Add(formatterProperty.Name, configValues);
- }
- }
- }
-
- private object GetConfigValue(JsonElement valueElement)
- {
- switch (valueElement.ValueKind)
- {
- case JsonValueKind.String:
- return valueElement.GetString();
- case JsonValueKind.False:
- case JsonValueKind.True:
- return valueElement.GetBoolean();
- case JsonValueKind.Number:
- return valueElement.GetDouble();
- }
-
- // if value is an embedded JSON object or array, we keep it as it is
- return valueElement;
- }
-}
\ No newline at end of file
diff --git a/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs
index 86678f0df..403f9074a 100644
--- a/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs
+++ b/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs
@@ -2,11 +2,12 @@
using Reqnroll.EnvironmentAccess;
using Reqnroll.Formatters.RuntimeSupport;
using System;
+using System.Collections.Generic;
using System.Text.Json;
namespace Reqnroll.Formatters.Configuration;
-public class JsonEnvironmentConfigurationResolver : FormattersConfigurationResolverBase, IJsonEnvironmentConfigurationResolver
+public class JsonEnvironmentConfigurationResolver : IJsonEnvironmentConfigurationResolver
{
private readonly IEnvironmentOptions _environmentOptions;
private readonly IFormatterLog _log;
@@ -19,7 +20,7 @@ public JsonEnvironmentConfigurationResolver(
_log = log;
}
- protected override JsonDocument GetJsonDocument()
+ public IDictionary> Resolve()
{
try
{
@@ -29,11 +30,7 @@ protected override JsonDocument GetJsonDocument()
{
try
{
- return JsonDocument.Parse(formattersJson, new JsonDocumentOptions
- {
- CommentHandling = JsonCommentHandling.Skip,
- AllowTrailingCommas = true // More lenient parsing
- });
+ return FormattersConfigExtractor.ExtractFormatters(formattersJson);
}
catch (JsonException ex)
{
@@ -51,6 +48,6 @@ protected override JsonDocument GetJsonDocument()
_log?.WriteMessage($"Unexpected error retrieving environment configuration: {ex.Message}");
}
- return null;
+ return new Dictionary>(StringComparer.OrdinalIgnoreCase);
}
}
\ No newline at end of file
diff --git a/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs b/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs
index 6352d6386..7187bd704 100644
--- a/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs
+++ b/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs
@@ -503,5 +503,116 @@ private void AssertDefaultJsonReqnrollConfiguration(ReqnrollConfiguration config
config.AdditionalStepAssemblies.Should().NotBeNull();
config.AdditionalStepAssemblies.Should().BeEmpty();
}
+
+ #region Formatters Deserialization Tests
+
+ [Fact]
+ public void Check_Formatters_IsNull_When_Not_Present()
+ {
+ string config = @"{}";
+
+ var jsonConfig = System.Text.Json.JsonSerializer.Deserialize(config, JsonConfigurationSourceGenerator.Default.JsonConfig);
+
+ jsonConfig.Formatters.Should().BeNull();
+ }
+
+ [Fact]
+ public void Check_Formatters_Html_OutputFilePath()
+ {
+ string config = @"{
+ ""formatters"": {
+ ""html"": { ""outputFilePath"": ""report.html"" }
+ }
+ }";
+
+ var jsonConfig = System.Text.Json.JsonSerializer.Deserialize(config, JsonConfigurationSourceGenerator.Default.JsonConfig);
+
+ jsonConfig.Formatters.Should().NotBeNull();
+ jsonConfig.Formatters.Html.Should().NotBeNull();
+ jsonConfig.Formatters.Html.OutputFilePath.Should().Be("report.html");
+ }
+
+ [Fact]
+ public void Check_Formatters_Message_OutputFilePath()
+ {
+ string config = @"{
+ ""formatters"": {
+ ""message"": { ""outputFilePath"": ""messages.ndjson"" }
+ }
+ }";
+
+ var jsonConfig = System.Text.Json.JsonSerializer.Deserialize(config, JsonConfigurationSourceGenerator.Default.JsonConfig);
+
+ jsonConfig.Formatters.Should().NotBeNull();
+ jsonConfig.Formatters.Message.Should().NotBeNull();
+ jsonConfig.Formatters.Message.OutputFilePath.Should().Be("messages.ndjson");
+ }
+
+ [Fact]
+ public void Check_Formatters_Multiple_Known_Formatters()
+ {
+ string config = @"{
+ ""formatters"": {
+ ""html"": { ""outputFilePath"": ""report.html"" },
+ ""message"": { ""outputFilePath"": ""messages.ndjson"" }
+ }
+ }";
+
+ var jsonConfig = System.Text.Json.JsonSerializer.Deserialize(config, JsonConfigurationSourceGenerator.Default.JsonConfig);
+
+ jsonConfig.Formatters.Html.OutputFilePath.Should().Be("report.html");
+ jsonConfig.Formatters.Message.OutputFilePath.Should().Be("messages.ndjson");
+ }
+
+ [Fact]
+ public void Check_Formatters_CustomFormatter_CapturedInAdditionalFormatters()
+ {
+ string config = @"{
+ ""formatters"": {
+ ""customFormatter"": { ""setting1"": ""value1"" }
+ }
+ }";
+
+ var jsonConfig = System.Text.Json.JsonSerializer.Deserialize(config, JsonConfigurationSourceGenerator.Default.JsonConfig);
+
+ jsonConfig.Formatters.Should().NotBeNull();
+ jsonConfig.Formatters.AdditionalFormatters.Should().NotBeNull();
+ jsonConfig.Formatters.AdditionalFormatters.Should().ContainKey("customFormatter");
+ }
+
+ [Fact]
+ public void Check_Formatters_Html_AdditionalOptions_Captured()
+ {
+ string config = @"{
+ ""formatters"": {
+ ""html"": {
+ ""outputFilePath"": ""report.html"",
+ ""customOption"": ""customValue""
+ }
+ }
+ }";
+
+ var jsonConfig = System.Text.Json.JsonSerializer.Deserialize(config, JsonConfigurationSourceGenerator.Default.JsonConfig);
+
+ jsonConfig.Formatters.Html.OutputFilePath.Should().Be("report.html");
+ jsonConfig.Formatters.Html.AdditionalOptions.Should().ContainKey("customOption");
+ }
+
+ [Fact]
+ public void Check_Formatters_EmptyFormatter_Parsed()
+ {
+ string config = @"{
+ ""formatters"": {
+ ""html"": {}
+ }
+ }";
+
+ var jsonConfig = System.Text.Json.JsonSerializer.Deserialize(config, JsonConfigurationSourceGenerator.Default.JsonConfig);
+
+ jsonConfig.Formatters.Html.Should().NotBeNull();
+ jsonConfig.Formatters.Html.OutputFilePath.Should().BeNull();
+ }
+
+ #endregion
}
}
diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs
new file mode 100644
index 000000000..493788b06
--- /dev/null
+++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs
@@ -0,0 +1,248 @@
+using FluentAssertions;
+using Reqnroll.Configuration.JsonConfig;
+using Reqnroll.Formatters.Configuration;
+using System.Collections.Generic;
+using System.Text.Json;
+using Xunit;
+
+namespace Reqnroll.RuntimeTests.Formatters.Configuration;
+
+public class FormattersConfigExtractorTests
+{
+ [Fact]
+ public void ExtractFormatters_Should_Return_Empty_Dictionary_When_Json_Is_Null()
+ {
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(null);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Return_Empty_Dictionary_When_Json_Is_Empty()
+ {
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters("");
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Return_Empty_Dictionary_When_Json_Has_No_Formatters()
+ {
+ // Arrange
+ var json = @"{ ""language"": { ""feature"": ""en"" } }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(json);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Return_Empty_Dictionary_For_Invalid_Json()
+ {
+ // Arrange
+ var invalidJson = "{ not valid json }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(invalidJson);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Extract_Known_Html_Formatter()
+ {
+ // Arrange
+ var json = @"{
+ ""formatters"": {
+ ""html"": { ""outputFilePath"": ""report.html"" }
+ }
+ }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(json);
+
+ // Assert
+ result.Should().ContainKey("html");
+ result["html"]["outputFilePath"].Should().Be("report.html");
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Extract_Known_Message_Formatter()
+ {
+ // Arrange
+ var json = @"{
+ ""formatters"": {
+ ""message"": { ""outputFilePath"": ""messages.ndjson"" }
+ }
+ }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(json);
+
+ // Assert
+ result.Should().ContainKey("message");
+ result["message"]["outputFilePath"].Should().Be("messages.ndjson");
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Extract_Custom_Formatter_Via_AdditionalFormatters()
+ {
+ // Arrange
+ var json = @"{
+ ""formatters"": {
+ ""customFormatter"": { ""setting1"": ""value1"", ""setting2"": ""value2"" }
+ }
+ }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(json);
+
+ // Assert
+ result.Should().ContainKey("customFormatter");
+ result["customFormatter"]["setting1"].Should().Be("value1");
+ result["customFormatter"]["setting2"].Should().Be("value2");
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Extract_Multiple_Formatters()
+ {
+ // Arrange
+ var json = @"{
+ ""formatters"": {
+ ""html"": { ""outputFilePath"": ""report.html"" },
+ ""message"": { ""outputFilePath"": ""messages.ndjson"" },
+ ""custom"": { ""customSetting"": ""customValue"" }
+ }
+ }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(json);
+
+ // Assert
+ result.Should().HaveCount(3);
+ result.Should().ContainKeys("html", "message", "custom");
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Handle_Additional_Options_On_Known_Formatter()
+ {
+ // Arrange
+ var json = @"{
+ ""formatters"": {
+ ""html"": {
+ ""outputFilePath"": ""report.html"",
+ ""customOption"": ""customValue""
+ }
+ }
+ }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(json);
+
+ // Assert
+ result["html"]["outputFilePath"].Should().Be("report.html");
+ result["html"]["customOption"].Should().Be("customValue");
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Handle_Boolean_Config_Values()
+ {
+ // Arrange
+ var json = @"{
+ ""formatters"": {
+ ""custom"": { ""enabled"": true, ""debug"": false }
+ }
+ }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(json);
+
+ // Assert
+ result["custom"]["enabled"].Should().Be(true);
+ result["custom"]["debug"].Should().Be(false);
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Handle_Numeric_Config_Values()
+ {
+ // Arrange
+ var json = @"{
+ ""formatters"": {
+ ""custom"": { ""timeout"": 30.5, ""retries"": 3 }
+ }
+ }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(json);
+
+ // Assert
+ result["custom"]["timeout"].Should().Be(30.5);
+ result["custom"]["retries"].Should().Be(3.0); // Numbers are parsed as double
+ }
+
+ [Fact]
+ public void ConvertFormattersElement_Should_Return_Empty_Dictionary_When_Null()
+ {
+ // Act
+ var result = FormattersConfigExtractor.ConvertFormattersElement(null);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ConvertFormattersElement_Should_Return_Empty_Dictionary_When_No_Formatters_Defined()
+ {
+ // Arrange
+ var element = new FormattersElement();
+
+ // Act
+ var result = FormattersConfigExtractor.ConvertFormattersElement(element);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ConvertFormattersElement_Should_Handle_Empty_Formatter_Options()
+ {
+ // Arrange
+ var element = new FormattersElement
+ {
+ Html = new FormatterOptionsElement()
+ };
+
+ // Act
+ var result = FormattersConfigExtractor.ConvertFormattersElement(element);
+
+ // Assert
+ result.Should().ContainKey("html");
+ result["html"].Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ExtractFormatters_Should_Be_Case_Insensitive_For_Formatter_Names()
+ {
+ // Arrange
+ var json = @"{
+ ""formatters"": {
+ ""HTML"": { ""outputFilePath"": ""report.html"" }
+ }
+ }";
+
+ // Act
+ var result = FormattersConfigExtractor.ExtractFormatters(json);
+
+ // Assert
+ // The key should be accessible case-insensitively due to StringComparer.OrdinalIgnoreCase
+ result["html"]["outputFilePath"].Should().Be("report.html");
+ result["HTML"]["outputFilePath"].Should().Be("report.html");
+ }
+}
From a55235a572f2bb7475a66adc46b4d519ef7f8292 Mon Sep 17 00:00:00 2001
From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com>
Date: Tue, 3 Feb 2026 13:56:27 -0600
Subject: [PATCH 2/5] Modified the primary interface contract for the
Formatters configuration from an untyped Dictionary<> to a typed contract
that supports untyped additional properties.
---
.../FileBasedConfigurationResolver.cs | 11 +-
.../Configuration/FormatterConfiguration.cs | 133 +++++++++
.../FormattersConfigExtractor.cs | 66 +++--
.../Configuration/FormattersConfiguration.cs | 6 +-
.../FormattersConfigurationProvider.cs | 28 +-
.../IFormattersConfigurationProvider.cs | 19 +-
.../IFormattersConfigurationResolverBase.cs | 9 +-
.../JsonEnvironmentConfigurationResolver.cs | 9 +-
...eyValueEnvironmentConfigurationResolver.cs | 27 +-
.../Formatters/FileWritingFormatterBase.cs | 56 +++-
Reqnroll/Formatters/FormatterBase.cs | 42 ++-
.../MessagesCompatibilityTestBase.cs | 7 +-
.../CucumberConfigurationTests.cs | 145 ++++++++--
.../FileBasedConfigurationResolverTests.cs | 9 +-
.../FormatterConfigurationTests.cs | 269 ++++++++++++++++++
.../FormattersConfigExtractorTests.cs | 27 +-
...onEnvironmentConfigurationResolverTests.cs | 10 +-
...ueEnvironmentConfigurationResolverTests.cs | 19 +-
.../FileWritingFormatterBaseTests.cs | 63 ++--
.../Formatters/FormatterBaseTests.cs | 20 +-
20 files changed, 821 insertions(+), 154 deletions(-)
create mode 100644 Reqnroll/Formatters/Configuration/FormatterConfiguration.cs
create mode 100644 Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormatterConfigurationTests.cs
diff --git a/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
index 95aff788a..c37e24c5d 100644
--- a/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
+++ b/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
@@ -27,11 +27,16 @@ public FileBasedConfigurationResolver(
_log = log;
}
- public IDictionary> Resolve()
+ ///
+ /// File-based configuration replaces entirely (does not merge with previous settings).
+ ///
+ public bool ShouldMergeSettings => false;
+
+ public IDictionary Resolve()
{
var jsonContent = GetJsonContent();
if (jsonContent == null)
- return new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
try
{
@@ -40,7 +45,7 @@ public IDictionary> Resolve()
catch (JsonException ex)
{
_log?.WriteMessage($"Failed to parse formatters configuration: {ex.Message}");
- return new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
}
}
diff --git a/Reqnroll/Formatters/Configuration/FormatterConfiguration.cs b/Reqnroll/Formatters/Configuration/FormatterConfiguration.cs
new file mode 100644
index 000000000..d30c51c1a
--- /dev/null
+++ b/Reqnroll/Formatters/Configuration/FormatterConfiguration.cs
@@ -0,0 +1,133 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+
+namespace Reqnroll.Formatters.Configuration;
+
+///
+/// Represents the configuration for a formatter with type-safe access to known properties
+/// and extensibility for custom settings.
+///
+public class FormatterConfiguration
+{
+ ///
+ /// The output file path for the formatter. May be null if not configured.
+ ///
+ public string? OutputFilePath { get; set; }
+
+ ///
+ /// Additional settings for the formatter that are not explicitly defined as properties.
+ ///
+ public IDictionary AdditionalSettings { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Creates a FormatterConfiguration from a dictionary representation.
+ ///
+ /// The dictionary containing formatter configuration values.
+ /// A new FormatterConfiguration instance, or null if the dictionary is null.
+ public static FormatterConfiguration? FromDictionary(IDictionary? dictionary)
+ {
+ if (dictionary == null)
+ return null;
+
+ var config = new FormatterConfiguration();
+ var additionalSettings = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var kvp in dictionary)
+ {
+ if (string.Equals(kvp.Key, "outputFilePath", StringComparison.OrdinalIgnoreCase))
+ {
+ config.OutputFilePath = kvp.Value?.ToString();
+ }
+ else if (kvp.Value != null)
+ {
+ additionalSettings[kvp.Key] = kvp.Value;
+ }
+ }
+
+ config.AdditionalSettings = additionalSettings;
+ return config;
+ }
+
+ ///
+ /// Converts this FormatterConfiguration back to a dictionary representation.
+ /// Used for backward compatibility with legacy APIs.
+ ///
+ /// A dictionary containing all configuration values.
+ public IDictionary ToDictionary()
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (OutputFilePath != null)
+ {
+ result["outputFilePath"] = OutputFilePath;
+ }
+
+ foreach (var kvp in AdditionalSettings)
+ {
+ result[kvp.Key] = kvp.Value;
+ }
+
+ return result;
+ }
+
+ ///
+ /// Gets a configuration value by key, checking both known properties and additional settings.
+ ///
+ /// The expected type of the value.
+ /// The configuration key.
+ /// The default value if the key is not found.
+ /// The configuration value or the default value.
+ public T? GetValue(string key, T? defaultValue = default)
+ {
+ if (string.Equals(key, "outputFilePath", StringComparison.OrdinalIgnoreCase))
+ {
+ if (OutputFilePath is T typedValue)
+ return typedValue;
+ return defaultValue;
+ }
+
+ if (AdditionalSettings.TryGetValue(key, out var value))
+ {
+ if (value is T typedValue)
+ return typedValue;
+
+ // Try conversion for common types
+ try
+ {
+ return (T)Convert.ChangeType(value, typeof(T));
+ }
+ catch
+ {
+ return defaultValue;
+ }
+ }
+
+ return defaultValue;
+ }
+
+ ///
+ /// Merges settings from another FormatterConfiguration into this one.
+ /// Only non-null values from the other configuration will override values in this configuration.
+ /// This allows partial overrides where only specified settings are changed.
+ ///
+ /// The configuration to merge from. Null values are ignored.
+ public void MergeFrom(FormatterConfiguration? other)
+ {
+ if (other == null)
+ return;
+
+ // Only override OutputFilePath if the other configuration has it set
+ if (other.OutputFilePath != null)
+ {
+ OutputFilePath = other.OutputFilePath;
+ }
+
+ // Merge additional settings - other's values override this's values
+ foreach (var kvp in other.AdditionalSettings)
+ {
+ AdditionalSettings[kvp.Key] = kvp.Value;
+ }
+ }
+}
diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs b/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
index 92ecbebf1..27844c972 100644
--- a/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
+++ b/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
@@ -12,14 +12,14 @@ namespace Reqnroll.Formatters.Configuration;
public static class FormattersConfigExtractor
{
///
- /// Deserializes JSON content and extracts the formatters configuration as a dictionary.
+ /// Deserializes JSON content and extracts the formatters configuration as typed FormatterConfiguration objects.
///
/// The JSON content to parse.
/// A dictionary of formatter configurations, or an empty dictionary if parsing fails or no formatters are defined.
- public static IDictionary> ExtractFormatters(string jsonContent)
+ public static IDictionary ExtractFormatters(string jsonContent)
{
if (string.IsNullOrWhiteSpace(jsonContent))
- return new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
try
{
@@ -28,18 +28,18 @@ public static IDictionary> ExtractFormatters
}
catch (JsonException)
{
- return new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
}
}
///
- /// Converts a FormattersElement to the dictionary format used by the formatters configuration system.
+ /// Converts a FormattersElement to typed FormatterConfiguration objects.
///
/// The FormattersElement to convert.
/// A dictionary of formatter configurations.
- public static IDictionary> ConvertFormattersElement(FormattersElement formatters)
+ public static IDictionary ConvertFormattersElement(FormattersElement formatters)
{
- var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
if (formatters == null)
return result;
@@ -62,17 +62,12 @@ public static IDictionary> ConvertFormatters
{
if (kvp.Value.ValueKind == JsonValueKind.Object)
{
- var configValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var property in kvp.Value.EnumerateObject())
- {
- configValues[property.Name] = GetConfigValue(property.Value);
- }
- result[kvp.Key] = configValues;
+ result[kvp.Key] = ConvertJsonElementToFormatterConfiguration(kvp.Value);
}
else
{
- // Non-object values get an empty config dictionary
- result[kvp.Key] = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ // Non-object values get an empty config
+ result[kvp.Key] = new FormatterConfiguration();
}
}
}
@@ -80,25 +75,50 @@ public static IDictionary> ConvertFormatters
return result;
}
- private static IDictionary ConvertFormatterOptions(FormatterOptionsElement options)
+ private static FormatterConfiguration ConvertFormatterOptions(FormatterOptionsElement options)
{
- var configValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- if (options.OutputFilePath != null)
+ var config = new FormatterConfiguration
{
- configValues["outputFilePath"] = options.OutputFilePath;
- }
+ OutputFilePath = options.OutputFilePath
+ };
// Process additional options captured by JsonExtensionData
if (options.AdditionalOptions != null)
{
foreach (var kvp in options.AdditionalOptions)
{
- configValues[kvp.Key] = GetConfigValue(kvp.Value);
+ var value = GetConfigValue(kvp.Value);
+ if (value != null)
+ {
+ config.AdditionalSettings[kvp.Key] = value;
+ }
+ }
+ }
+
+ return config;
+ }
+
+ private static FormatterConfiguration ConvertJsonElementToFormatterConfiguration(JsonElement element)
+ {
+ var config = new FormatterConfiguration();
+
+ foreach (var property in element.EnumerateObject())
+ {
+ if (string.Equals(property.Name, "outputFilePath", StringComparison.OrdinalIgnoreCase))
+ {
+ config.OutputFilePath = property.Value.GetString();
+ }
+ else
+ {
+ var value = GetConfigValue(property.Value);
+ if (value != null)
+ {
+ config.AdditionalSettings[property.Name] = value;
+ }
}
}
- return configValues;
+ return config;
}
private static object GetConfigValue(JsonElement valueElement)
diff --git a/Reqnroll/Formatters/Configuration/FormattersConfiguration.cs b/Reqnroll/Formatters/Configuration/FormattersConfiguration.cs
index 9c54adca8..f6cd77dea 100644
--- a/Reqnroll/Formatters/Configuration/FormattersConfiguration.cs
+++ b/Reqnroll/Formatters/Configuration/FormattersConfiguration.cs
@@ -5,5 +5,9 @@ namespace Reqnroll.Formatters.Configuration;
public class FormattersConfiguration
{
public bool Enabled { get; set; }
- public IDictionary> Formatters { get; set; }
+
+ ///
+ /// Formatter configurations with type-safe access to known properties.
+ ///
+ public IDictionary Formatters { get; set; }
}
\ No newline at end of file
diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs b/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs
index 93e31c491..d199cf72c 100644
--- a/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs
+++ b/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs
@@ -31,7 +31,8 @@ public FormattersConfigurationProvider(IFileBasedConfigurationResolver fileBased
_variableSubstitutionService = variableSubstitutionService;
}
- public IDictionary GetFormatterConfigurationByName(string formatterName)
+ ///
+ public FormatterConfiguration GetFormatterConfiguration(string formatterName)
{
var config = _resolvedConfiguration.Value;
if (config.Formatters.TryGetValue(formatterName, out var formatterConfig))
@@ -39,18 +40,37 @@ public IDictionary GetFormatterConfigurationByName(string format
return null;
}
+ ///
+ [Obsolete("Use GetFormatterConfiguration instead for type-safe access to configuration values.")]
+ public IDictionary GetFormatterConfigurationByName(string formatterName)
+ {
+ var config = GetFormatterConfiguration(formatterName);
+ return config?.ToDictionary();
+ }
+
private FormattersConfiguration ResolveConfiguration()
{
- var combinedConfig = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ var combinedConfig = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var resolver in _resolvers)
{
foreach (var entry in resolver.Resolve())
{
- if (entry.Value == null)
+ if (entry.Value == null)
+ {
+ // null means "disable this formatter"
combinedConfig.Remove(entry.Key);
- else
+ }
+ else if (resolver.ShouldMergeSettings && combinedConfig.TryGetValue(entry.Key, out var existing))
+ {
+ // Merge: only override settings that are explicitly set in the new config
+ existing.MergeFrom(entry.Value);
+ }
+ else
+ {
+ // Replace: set the entire configuration
combinedConfig[entry.Key] = entry.Value;
+ }
}
}
bool enabled = combinedConfig.Count > 0 && !_envVariableDisableFlagProvider.Disabled();
diff --git a/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs b/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs
index 446e92b2d..f0e457326 100644
--- a/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs
+++ b/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs
@@ -1,10 +1,27 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
namespace Reqnroll.Formatters.Configuration;
public interface IFormattersConfigurationProvider
{
bool Enabled { get; }
+
+ ///
+ /// Gets the typed configuration for a formatter by name.
+ ///
+ /// The name of the formatter.
+ /// The FormatterConfiguration, or null if the formatter is not configured.
+ FormatterConfiguration GetFormatterConfiguration(string formatterName);
+
+ ///
+ /// Gets the configuration for a formatter by name as a dictionary.
+ ///
+ /// The name of the formatter.
+ /// The configuration dictionary, or null if the formatter is not configured.
+ [Obsolete("Use GetFormatterConfiguration instead for type-safe access to configuration values.")]
+ [EditorBrowsable(EditorBrowsableState.Never)]
IDictionary GetFormatterConfigurationByName(string formatterName);
string ResolveTemplatePlaceholders(string template);
diff --git a/Reqnroll/Formatters/Configuration/IFormattersConfigurationResolverBase.cs b/Reqnroll/Formatters/Configuration/IFormattersConfigurationResolverBase.cs
index a6fc788bb..66babc0b8 100644
--- a/Reqnroll/Formatters/Configuration/IFormattersConfigurationResolverBase.cs
+++ b/Reqnroll/Formatters/Configuration/IFormattersConfigurationResolverBase.cs
@@ -4,5 +4,12 @@ namespace Reqnroll.Formatters.Configuration;
public interface IFormattersConfigurationResolverBase
{
- IDictionary> Resolve();
+ IDictionary Resolve();
+
+ ///
+ /// Indicates whether this resolver's settings should be merged with existing settings (true)
+ /// or should completely replace them (false).
+ /// KeyValue-based resolvers typically merge individual settings, while JSON-based resolvers replace entirely.
+ ///
+ bool ShouldMergeSettings { get; }
}
\ No newline at end of file
diff --git a/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs
index 403f9074a..b6a1083c0 100644
--- a/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs
+++ b/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs
@@ -20,7 +20,12 @@ public JsonEnvironmentConfigurationResolver(
_log = log;
}
- public IDictionary> Resolve()
+ ///
+ /// JSON-based configuration replaces entirely (does not merge with previous settings).
+ ///
+ public bool ShouldMergeSettings => false;
+
+ public IDictionary Resolve()
{
try
{
@@ -48,6 +53,6 @@ public IDictionary> Resolve()
_log?.WriteMessage($"Unexpected error retrieving environment configuration: {ex.Message}");
}
- return new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
}
}
\ No newline at end of file
diff --git a/Reqnroll/Formatters/Configuration/KeyValueEnvironmentConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/KeyValueEnvironmentConfigurationResolver.cs
index 209169438..0f543870b 100644
--- a/Reqnroll/Formatters/Configuration/KeyValueEnvironmentConfigurationResolver.cs
+++ b/Reqnroll/Formatters/Configuration/KeyValueEnvironmentConfigurationResolver.cs
@@ -9,9 +9,14 @@ internal class KeyValueEnvironmentConfigurationResolver(IEnvironmentOptions envi
{
private readonly IEnvironmentOptions _environmentWrapper = environmentOptions ?? throw new ArgumentNullException(nameof(environmentOptions));
- public IDictionary> Resolve()
+ ///
+ /// KeyValue-based configuration merges with existing settings (only overrides specified values).
+ ///
+ public bool ShouldMergeSettings => true;
+
+ public IDictionary Resolve()
{
- var result = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
var environmentVariables = _environmentWrapper.FormatterSettings;
foreach (var formatterEnvironmentVariable in environmentVariables)
@@ -27,7 +32,7 @@ public IDictionary> Resolve()
if (formatterConfiguration.Equals("true", StringComparison.InvariantCultureIgnoreCase))
{
- result[formatterName] = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ result[formatterName] = new FormatterConfiguration();
continue;
}
@@ -39,17 +44,27 @@ public IDictionary> Resolve()
var settings = formatterConfiguration.Split(';');
- var configValues = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var config = new FormatterConfiguration();
foreach (string setting in settings)
{
var keyValue = setting.Split(['='], 2);
if (keyValue.Length == 1)
throw new ReqnrollException($"Could not parse setting '{setting}' for formatter '{formatterName}' when processing the environment variable {formatterEnvironmentVariable.Key}. Please use semicolon separated list of 'key=value' settings or 'true'.");
- configValues[keyValue[0].Trim()] = keyValue[1].Trim();
+ var key = keyValue[0].Trim();
+ var value = keyValue[1].Trim();
+
+ if (string.Equals(key, "outputFilePath", StringComparison.OrdinalIgnoreCase))
+ {
+ config.OutputFilePath = value;
+ }
+ else
+ {
+ config.AdditionalSettings[key] = value;
+ }
}
- result[formatterName] = configValues;
+ result[formatterName] = config;
}
return result;
diff --git a/Reqnroll/Formatters/FileWritingFormatterBase.cs b/Reqnroll/Formatters/FileWritingFormatterBase.cs
index 900740ebd..59cf11de2 100644
--- a/Reqnroll/Formatters/FileWritingFormatterBase.cs
+++ b/Reqnroll/Formatters/FileWritingFormatterBase.cs
@@ -41,10 +41,15 @@ protected FileWritingFormatterBase(
protected const int TUNING_PARAM_FILE_WRITE_BUFFER_SIZE = 65536;
- public override void LaunchInner(IDictionary formatterConfiguration, Action onInitialized)
+ ///
+ /// Initializes the file-writing formatter with the typed configuration.
+ ///
+ /// The typed formatter configuration.
+ /// Callback to report initialization success or failure.
+ public override void LaunchInner(FormatterConfiguration configuration, Action onInitialized)
{
var defaultBaseDirectory = ".";
- var configuredPath = ConfiguredOutputFilePath(formatterConfiguration)?.Trim();
+ var configuredPath = GetOutputFilePath(configuration)?.Trim();
configuredPath = ResolveOutputFilePathVariables(configuredPath);
string outputPath;
string baseDirectory;
@@ -106,10 +111,21 @@ public override void LaunchInner(IDictionary formatterConfigurat
}
}
- FinalizeInitialization(outputPath, formatterConfiguration, onInitialized);
+ FinalizeInitialization(outputPath, configuration, onInitialized);
Logger.WriteMessage($"Formatter {Name} initialized to write to: {outputPath}.");
}
+ ///
+ /// Legacy method - calls the new typed version.
+ ///
+ [Obsolete("Override LaunchInner(FormatterConfiguration, Action) instead.")]
+ public override void LaunchInner(IDictionary formatterConfiguration, Action onInitialized)
+ {
+ // Convert and call the typed version
+ var config = FormatterConfiguration.FromDictionary(formatterConfiguration) ?? new FormatterConfiguration();
+ LaunchInner(config, onInitialized);
+ }
+
public virtual string? ResolveOutputFilePathVariables(string? configuredFilePath)
{
return ConfigurationProvider.ResolveTemplatePlaceholders(configuredFilePath);
@@ -162,7 +178,13 @@ protected override async Task ConsumeAndFormatMessagesBackgroundTask(Cancellatio
}
}
- protected virtual void FinalizeInitialization(string outputPath, IDictionary formatterConfiguration, Action onInitialized)
+ ///
+ /// Finalizes the initialization of the formatter by creating the target file stream.
+ ///
+ /// The resolved output file path.
+ /// The typed formatter configuration.
+ /// Callback to report initialization success or failure.
+ protected virtual void FinalizeInitialization(string outputPath, FormatterConfiguration configuration, Action onInitialized)
{
try
{
@@ -181,6 +203,16 @@ protected virtual void FinalizeInitialization(string outputPath, IDictionary
+ /// Legacy method for backward compatibility.
+ ///
+ [Obsolete("Override FinalizeInitialization(string, FormatterConfiguration, Action) instead.")]
+ protected virtual void FinalizeInitialization(string outputPath, IDictionary formatterConfiguration, Action onInitialized)
+ {
+ var config = FormatterConfiguration.FromDictionary(formatterConfiguration) ?? new FormatterConfiguration();
+ FinalizeInitialization(outputPath, config, onInitialized);
+ }
+
protected virtual Stream CreateTargetFileStream(string outputPath) =>
File.Create(outputPath, TUNING_PARAM_FILE_WRITE_BUFFER_SIZE);
@@ -188,12 +220,26 @@ protected virtual Stream CreateTargetFileStream(string outputPath) =>
protected abstract void OnTargetFileStreamDisposing();
protected abstract Task WriteToFile(Envelope envelope, CancellationToken cancellationToken);
+ ///
+ /// Gets the configured output file path from the typed configuration.
+ ///
+ /// The typed formatter configuration.
+ /// The configured output file path, or empty string if not configured.
+ protected virtual string GetOutputFilePath(FormatterConfiguration configuration)
+ {
+ return configuration?.OutputFilePath ?? string.Empty;
+ }
+
+ ///
+ /// Legacy method for getting the output file path from a dictionary configuration.
+ ///
+ [Obsolete("Override GetOutputFilePath(FormatterConfiguration) instead.")]
protected virtual string ConfiguredOutputFilePath(IDictionary formatterConfiguration)
{
string outputFilePath = string.Empty;
if (formatterConfiguration.TryGetValue("outputFilePath", out var outputPathElement))
{
- outputFilePath = outputPathElement?.ToString() ?? string.Empty; // Ensure null-coalescing to handle possible null values.
+ outputFilePath = outputPathElement?.ToString() ?? string.Empty;
}
return outputFilePath;
}
diff --git a/Reqnroll/Formatters/FormatterBase.cs b/Reqnroll/Formatters/FormatterBase.cs
index c152ee6e7..eb521283b 100644
--- a/Reqnroll/Formatters/FormatterBase.cs
+++ b/Reqnroll/Formatters/FormatterBase.cs
@@ -35,6 +35,11 @@ public abstract class FormatterBase : ICucumberMessageFormatter, IDisposable
public string Name => _pluginName;
+ ///
+ /// The resolved configuration for this formatter. Available after LaunchFormatter is called.
+ ///
+ protected FormatterConfiguration? Configuration { get; private set; }
+
protected FormatterBase(IFormattersConfigurationProvider configurationProvider, IFormatterLog logger, string pluginName)
{
_configurationProvider = configurationProvider;
@@ -48,12 +53,12 @@ public void LaunchFormatter(ICucumberMessageBroker broker)
_logger.WriteMessage($"DEBUG: Formatters: Formatter plugin: {Name} in Launch().");
_broker = broker;
- bool IsFormatterEnabled(out IDictionary configuration)
+ bool IsFormatterEnabled(out FormatterConfiguration? configuration)
{
- configuration = null!;
+ configuration = null;
if (!_configurationProvider.Enabled)
return false;
- configuration = _configurationProvider.GetFormatterConfigurationByName(_pluginName);
+ configuration = _configurationProvider.GetFormatterConfiguration(_pluginName);
return configuration != null;
}
@@ -65,12 +70,37 @@ bool IsFormatterEnabled(out IDictionary configuration)
ReportInitialized(false);
return;
}
- LaunchInner(formatterConfiguration, ReportInitialized);
+
+ Configuration = formatterConfiguration;
+ LaunchInner(formatterConfiguration!, ReportInitialized);
_formatterTask = Task.Run(() => ConsumeAndFormatMessagesBackgroundTask(_cancellationTokenSource.Token));
}
- // Method available to sinks to allow them to initialize.
- public abstract void LaunchInner(IDictionary formatterConfigString, Action onAfterInitialization);
+ ///
+ /// Called to initialize the formatter with its configuration. Override this method in derived classes.
+ ///
+ /// The typed formatter configuration.
+ /// Callback to report initialization success or failure.
+ public virtual void LaunchInner(FormatterConfiguration configuration, Action onAfterInitialization)
+ {
+ // Default implementation calls the legacy dictionary-based method for backward compatibility
+ // Derived classes that override the old method will continue to work
+#pragma warning disable CS0618 // Type or member is obsolete
+ LaunchInner(configuration.ToDictionary(), onAfterInitialization);
+#pragma warning restore CS0618
+ }
+
+ ///
+ /// Legacy method for initializing the formatter. Override LaunchInner(FormatterConfiguration, Action<bool>) instead.
+ ///
+ /// The formatter configuration as a dictionary.
+ /// Callback to report initialization success or failure.
+ [Obsolete("Override LaunchInner(FormatterConfiguration, Action) instead for type-safe configuration access.")]
+ public virtual void LaunchInner(IDictionary formatterConfiguration, Action onAfterInitialization)
+ {
+ // Default empty implementation - derived classes that override the new method don't need to implement this
+ onAfterInitialization(true);
+ }
private void ReportInitialized(bool status)
{
diff --git a/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs b/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs
index 856177839..f5bab9220 100644
--- a/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs
+++ b/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs
@@ -172,7 +172,8 @@ protected static string ActualResultLocationDirectory()
var jsonEnvConfigResolver = new JsonEnvironmentConfigurationResolver(envOptions);
var keyValueEnvironmentConfigurationResolverMock = new Mock();
- keyValueEnvironmentConfigurationResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>());
+ keyValueEnvironmentConfigurationResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary());
+ keyValueEnvironmentConfigurationResolverMock.Setup(r => r.ShouldMergeSettings).Returns(true);
FormattersConfigurationProvider configurationProvider = new FormattersConfigurationProvider(
configFileResolver,
@@ -181,9 +182,9 @@ protected static string ActualResultLocationDirectory()
new FormattersDisabledOverrideProvider(envOptions),
substitutionServiceMock.Object);
- configurationProvider.GetFormatterConfigurationByName("message").TryGetValue("outputFilePath", out var outputFilePathElement);
+ var messageConfig = configurationProvider.GetFormatterConfiguration("message");
+ var outputFilePath = messageConfig?.OutputFilePath ?? "";
- var outputFilePath = outputFilePathElement!.ToString();
if (string.IsNullOrEmpty(outputFilePath))
outputFilePath = "[BASEDIRECTORY]\\CucumberMessages\\reqnroll_report.ndson";
diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs
index fd1a8fa56..bb9f2f9c9 100644
--- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs
+++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs
@@ -10,7 +10,8 @@ public class CucumberConfigurationTests
{
private readonly Mock _disableOverrideProviderMock;
private readonly Mock _fileResolverMock;
- private readonly Mock _environmentResolverMock;
+ private readonly Mock _jsonEnvironmentResolverMock;
+ private readonly Mock _keyValueEnvironmentResolverMock;
private readonly Mock _variableSubstitutionServiceMock;
private readonly FormattersConfigurationProvider _sut;
@@ -18,15 +19,24 @@ public CucumberConfigurationTests()
{
_disableOverrideProviderMock = new Mock();
_fileResolverMock = new Mock();
- _environmentResolverMock = new Mock();
+ _jsonEnvironmentResolverMock = new Mock();
+ _keyValueEnvironmentResolverMock = new Mock();
_variableSubstitutionServiceMock = new Mock();
- var keyValueEnvironmentConfigurationResolverMock = new Mock();
- keyValueEnvironmentConfigurationResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>());
+
+ // Setup ShouldMergeSettings for each resolver
+ _fileResolverMock.Setup(r => r.ShouldMergeSettings).Returns(false);
+ _jsonEnvironmentResolverMock.Setup(r => r.ShouldMergeSettings).Returns(false);
+ _keyValueEnvironmentResolverMock.Setup(r => r.ShouldMergeSettings).Returns(true);
+
+ // Default empty returns
+ _fileResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary());
+ _jsonEnvironmentResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary());
+ _keyValueEnvironmentResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary());
_sut = new FormattersConfigurationProvider(
_fileResolverMock.Object,
- _environmentResolverMock.Object,
- keyValueEnvironmentConfigurationResolverMock.Object,
+ _jsonEnvironmentResolverMock.Object,
+ _keyValueEnvironmentResolverMock.Object,
_disableOverrideProviderMock.Object,
_variableSubstitutionServiceMock.Object);
}
@@ -35,8 +45,6 @@ public CucumberConfigurationTests()
public void Enabled_Should_Return_False_When_No_Configuration_Is_Resolved()
{
// Arrange
- _fileResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>());
- _environmentResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>());
_disableOverrideProviderMock.Setup(p => p.Disabled()).Returns(false);
// Act
@@ -50,11 +58,11 @@ public void Enabled_Should_Return_False_When_No_Configuration_Is_Resolved()
public void Enabled_Should_Respect_Environment_Variable_Override()
{
// Arrange
- var mockedSetup = new Dictionary>();
- var htmlConfig = new Dictionary { { "outputFileName", @"c:\html\html_report.html" } };
- mockedSetup.Add("html", htmlConfig);
+ var mockedSetup = new Dictionary
+ {
+ { "html", new FormatterConfiguration { AdditionalSettings = new Dictionary { { "outputFileName", @"c:\html\html_report.html" } } } }
+ };
_fileResolverMock.Setup(r => r.Resolve()).Returns(mockedSetup);
- _environmentResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>());
_disableOverrideProviderMock.Setup(p => p.Disabled()).Returns(true);
@@ -66,51 +74,128 @@ public void Enabled_Should_Respect_Environment_Variable_Override()
}
[Fact]
- public void GetFormatterConfigurationByName_Should_Return_Configuration_For_Existing_Formatter()
+ public void GetFormatterConfiguration_Should_Return_Configuration_For_Existing_Formatter()
{
// Arrange
- var mockedSetup = new Dictionary> { { "html", new Dictionary { { "outputFileName", @"c:\html\html_report.html" } } } };
+ var mockedSetup = new Dictionary
+ {
+ { "html", new FormatterConfiguration { AdditionalSettings = new Dictionary { { "outputFileName", @"c:\html\html_report.html" } } } }
+ };
_fileResolverMock.Setup(r => r.Resolve()).Returns(mockedSetup);
- _environmentResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>());
_disableOverrideProviderMock.Setup(p => p.Disabled()).Returns(false);
+
// Act
- var result = _sut.GetFormatterConfigurationByName("html");
+ var result = _sut.GetFormatterConfiguration("html");
// Assert
- Assert.Equal(@"c:\html\html_report.html", result["outputFileName"]);
+ Assert.Equal(@"c:\html\html_report.html", result.AdditionalSettings["outputFileName"]);
}
[Fact]
- public void GetFormatterConfigurationByName_Should_Respect_Formatter_Given_By_EnvironmentVariable_Override()
+ public void GetFormatterConfiguration_JsonEnvVar_Should_Replace_Entire_Configuration()
{
- // Arrange
- var configFileSetup = new Dictionary> { { "html", new Dictionary { { "outputFileName", @"c:\html\html_report.html" } } } };
+ // Arrange - Config file has outputFilePath and setting1
+ var configFileSetup = new Dictionary
+ {
+ { "html", new FormatterConfiguration
+ {
+ OutputFilePath = @"c:\html\report.html",
+ AdditionalSettings = new Dictionary { { "setting1", "fileValue" } }
+ }
+ }
+ };
_fileResolverMock.Setup(r => r.Resolve()).Returns(configFileSetup);
- var envVarSetup = new Dictionary> { { "html", new Dictionary { { "outputFileName", @"c:\html\html_overridden_name.html" } } } };
- _environmentResolverMock.Setup(r => r.Resolve()).Returns(envVarSetup);
+ // JSON environment variable (ShouldMergeSettings=false) REPLACES entirely
+ var envVarSetup = new Dictionary
+ {
+ { "html", new FormatterConfiguration
+ {
+ AdditionalSettings = new Dictionary { { "setting1", "envValue" } }
+ }
+ }
+ };
+ _jsonEnvironmentResolverMock.Setup(r => r.Resolve()).Returns(envVarSetup);
_disableOverrideProviderMock.Setup(p => p.Disabled()).Returns(false);
// Act
- var result = _sut.GetFormatterConfigurationByName("html");
+ var result = _sut.GetFormatterConfiguration("html");
- // Assert
- Assert.Equal(@"c:\html\html_overridden_name.html", result["outputFileName"]);
+ // Assert - JSON env var REPLACES, so outputFilePath should be LOST
+ Assert.Equal("envValue", result.AdditionalSettings["setting1"]);
+ Assert.Null(result.OutputFilePath); // Replaced entirely, so outputFilePath is gone
+ }
+
+ [Fact]
+ public void GetFormatterConfiguration_KeyValueEnvVar_Should_Merge_Settings_Not_Replace()
+ {
+ // Arrange - Config file has outputFilePath and setting1
+ var configFileSetup = new Dictionary
+ {
+ { "html", new FormatterConfiguration
+ {
+ OutputFilePath = @"c:\html\report.html",
+ AdditionalSettings = new Dictionary { { "setting1", "fileValue" } }
+ }
+ }
+ };
+ _fileResolverMock.Setup(r => r.Resolve()).Returns(configFileSetup);
+
+ // KeyValue environment variable (ShouldMergeSettings=true) MERGES
+ var keyValueSetup = new Dictionary
+ {
+ { "html", new FormatterConfiguration
+ {
+ AdditionalSettings = new Dictionary { { "setting1", "envValue" } }
+ }
+ }
+ };
+ _keyValueEnvironmentResolverMock.Setup(r => r.Resolve()).Returns(keyValueSetup);
+ _disableOverrideProviderMock.Setup(p => p.Disabled()).Returns(false);
+
+ // Act
+ var result = _sut.GetFormatterConfiguration("html");
+
+ // Assert - KeyValue env var MERGES, so outputFilePath should be PRESERVED
+ Assert.Equal("envValue", result.AdditionalSettings["setting1"]);
+ Assert.Equal(@"c:\html\report.html", result.OutputFilePath); // Merged, so outputFilePath is preserved!
+ }
+
+ [Fact]
+ public void GetFormatterConfiguration_KeyValueEnvVar_Should_Override_OutputFilePath_When_Specified()
+ {
+ // Arrange - Config file has outputFilePath
+ var configFileSetup = new Dictionary
+ {
+ { "html", new FormatterConfiguration { OutputFilePath = @"c:\html\original.html" } }
+ };
+ _fileResolverMock.Setup(r => r.Resolve()).Returns(configFileSetup);
+
+ // KeyValue environment variable specifies a different outputFilePath
+ var keyValueSetup = new Dictionary
+ {
+ { "html", new FormatterConfiguration { OutputFilePath = @"c:\html\overridden.html" } }
+ };
+ _keyValueEnvironmentResolverMock.Setup(r => r.Resolve()).Returns(keyValueSetup);
+ _disableOverrideProviderMock.Setup(p => p.Disabled()).Returns(false);
+
+ // Act
+ var result = _sut.GetFormatterConfiguration("html");
+
+ // Assert - outputFilePath should be overridden
+ Assert.Equal(@"c:\html\overridden.html", result.OutputFilePath);
}
[Fact]
- public void GetFormatterConfigurationByName_Should_Return_Null_For_Nonexistent_Formatter()
+ public void GetFormatterConfiguration_Should_Return_Null_For_Nonexistent_Formatter()
{
// Arrange
- _fileResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>());
- _environmentResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>());
-
_disableOverrideProviderMock.Setup(p => p.Disabled()).Returns(false);
// Act
- var result = _sut.GetFormatterConfigurationByName("nonexistent");
+ var result = _sut.GetFormatterConfiguration("nonexistent");
// Assert
Assert.Null(result);
diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FileBasedConfigurationResolverTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FileBasedConfigurationResolverTests.cs
index d83fafb60..8b5478562 100644
--- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FileBasedConfigurationResolverTests.cs
+++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FileBasedConfigurationResolverTests.cs
@@ -91,8 +91,8 @@ public void Resolve_Should_Return_Formatters_From_Valid_File()
// Assert
result.Should().HaveCount(2);
- result["formatter1"]["config1"].Should().Be("setting1");
- result["formatter2"]["config2"].Should().Be("setting2");
+ result["formatter1"].AdditionalSettings["config1"].Should().Be("setting1");
+ result["formatter2"].AdditionalSettings["config2"].Should().Be("setting2");
}
[Fact]
@@ -105,7 +105,7 @@ public void Resolve_Should_Handle_Invalid_Json_File_ByEmittingLog_and_ReturningE
_jsonLocatorMock.Setup(locator => locator.GetReqnrollJsonFilePath()).Returns(filePath);
_fileSystemMock.Setup(fs => fs.FileExists(filePath)).Returns(true);
_fileServiceMock.Setup(fs => fs.ReadAllText(filePath)).Returns(invalidJsonContent);
- IDictionary> result;
+ IDictionary result = null;
// Act
var act = () => result = _sut.Resolve();
@@ -157,6 +157,7 @@ public void Resolve_Should_Return_An_EmptyEntry_When_Key_Has_no_Content()
var result = _sut.Resolve();
// Assert
result.Should().HaveCount(1);
- result["emptyFormatter"].Should().BeEmpty();
+ result["emptyFormatter"].OutputFilePath.Should().BeNull();
+ result["emptyFormatter"].AdditionalSettings.Should().BeEmpty();
}
}
\ No newline at end of file
diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormatterConfigurationTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormatterConfigurationTests.cs
new file mode 100644
index 000000000..3d67c2f1e
--- /dev/null
+++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormatterConfigurationTests.cs
@@ -0,0 +1,269 @@
+using FluentAssertions;
+using Reqnroll.Formatters.Configuration;
+using System.Collections.Generic;
+using Xunit;
+
+namespace Reqnroll.RuntimeTests.Formatters.Configuration;
+
+public class FormatterConfigurationTests
+{
+ [Fact]
+ public void FromDictionary_Should_Return_Null_When_Dictionary_Is_Null()
+ {
+ // Act
+ var result = FormatterConfiguration.FromDictionary(null);
+
+ // Assert
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public void FromDictionary_Should_Extract_OutputFilePath()
+ {
+ // Arrange
+ var dictionary = new Dictionary
+ {
+ { "outputFilePath", "test/path.txt" }
+ };
+
+ // Act
+ var result = FormatterConfiguration.FromDictionary(dictionary);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.OutputFilePath.Should().Be("test/path.txt");
+ result.AdditionalSettings.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void FromDictionary_Should_Extract_OutputFilePath_Case_Insensitive()
+ {
+ // Arrange
+ var dictionary = new Dictionary
+ {
+ { "OUTPUTFILEPATH", "test/path.txt" }
+ };
+
+ // Act
+ var result = FormatterConfiguration.FromDictionary(dictionary);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.OutputFilePath.Should().Be("test/path.txt");
+ }
+
+ [Fact]
+ public void FromDictionary_Should_Put_Unknown_Keys_In_AdditionalSettings()
+ {
+ // Arrange
+ var dictionary = new Dictionary
+ {
+ { "outputFilePath", "test/path.txt" },
+ { "customSetting1", "value1" },
+ { "customSetting2", 42 }
+ };
+
+ // Act
+ var result = FormatterConfiguration.FromDictionary(dictionary);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.OutputFilePath.Should().Be("test/path.txt");
+ result.AdditionalSettings.Should().HaveCount(2);
+ result.AdditionalSettings["customSetting1"].Should().Be("value1");
+ result.AdditionalSettings["customSetting2"].Should().Be(42);
+ }
+
+ [Fact]
+ public void FromDictionary_Should_Ignore_Null_Values_In_AdditionalSettings()
+ {
+ // Arrange
+ var dictionary = new Dictionary
+ {
+ { "nullSetting", null! }
+ };
+
+ // Act
+ var result = FormatterConfiguration.FromDictionary(dictionary);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.AdditionalSettings.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void ToDictionary_Should_Include_OutputFilePath()
+ {
+ // Arrange
+ var config = new FormatterConfiguration
+ {
+ OutputFilePath = "test/path.txt"
+ };
+
+ // Act
+ var result = config.ToDictionary();
+
+ // Assert
+ result.Should().ContainKey("outputFilePath");
+ result["outputFilePath"].Should().Be("test/path.txt");
+ }
+
+ [Fact]
+ public void ToDictionary_Should_Not_Include_OutputFilePath_When_Null()
+ {
+ // Arrange
+ var config = new FormatterConfiguration
+ {
+ OutputFilePath = null
+ };
+
+ // Act
+ var result = config.ToDictionary();
+
+ // Assert
+ result.Should().NotContainKey("outputFilePath");
+ }
+
+ [Fact]
+ public void ToDictionary_Should_Include_AdditionalSettings()
+ {
+ // Arrange
+ var config = new FormatterConfiguration
+ {
+ OutputFilePath = "test/path.txt",
+ AdditionalSettings = new Dictionary
+ {
+ { "setting1", "value1" },
+ { "setting2", 123 }
+ }
+ };
+
+ // Act
+ var result = config.ToDictionary();
+
+ // Assert
+ result.Should().HaveCount(3);
+ result["outputFilePath"].Should().Be("test/path.txt");
+ result["setting1"].Should().Be("value1");
+ result["setting2"].Should().Be(123);
+ }
+
+ [Fact]
+ public void RoundTrip_FromDictionary_ToDictionary_Should_Preserve_Data()
+ {
+ // Arrange
+ var original = new Dictionary
+ {
+ { "outputFilePath", "test/path.txt" },
+ { "customSetting", "customValue" }
+ };
+
+ // Act
+ var config = FormatterConfiguration.FromDictionary(original);
+ var result = config!.ToDictionary();
+
+ // Assert
+ result["outputFilePath"].Should().Be("test/path.txt");
+ result["customSetting"].Should().Be("customValue");
+ }
+
+ [Fact]
+ public void GetValue_Should_Return_OutputFilePath_For_Known_Key()
+ {
+ // Arrange
+ var config = new FormatterConfiguration
+ {
+ OutputFilePath = "test/path.txt"
+ };
+
+ // Act
+ var result = config.GetValue("outputFilePath");
+
+ // Assert
+ result.Should().Be("test/path.txt");
+ }
+
+ [Fact]
+ public void GetValue_Should_Return_OutputFilePath_Case_Insensitive()
+ {
+ // Arrange
+ var config = new FormatterConfiguration
+ {
+ OutputFilePath = "test/path.txt"
+ };
+
+ // Act
+ var result = config.GetValue("OUTPUTFILEPATH");
+
+ // Assert
+ result.Should().Be("test/path.txt");
+ }
+
+ [Fact]
+ public void GetValue_Should_Return_AdditionalSetting_Value()
+ {
+ // Arrange
+ var config = new FormatterConfiguration
+ {
+ AdditionalSettings = new Dictionary
+ {
+ { "mySetting", "myValue" }
+ }
+ };
+
+ // Act
+ var result = config.GetValue("mySetting");
+
+ // Assert
+ result.Should().Be("myValue");
+ }
+
+ [Fact]
+ public void GetValue_Should_Return_Default_When_Key_Not_Found()
+ {
+ // Arrange
+ var config = new FormatterConfiguration();
+
+ // Act
+ var result = config.GetValue("nonExistent", "defaultValue");
+
+ // Assert
+ result.Should().Be("defaultValue");
+ }
+
+ [Fact]
+ public void GetValue_Should_Convert_Numeric_Types()
+ {
+ // Arrange
+ var config = new FormatterConfiguration
+ {
+ AdditionalSettings = new Dictionary
+ {
+ { "intValue", 42.0 } // Stored as double from JSON
+ }
+ };
+
+ // Act
+ var result = config.GetValue("intValue");
+
+ // Assert
+ result.Should().Be(42);
+ }
+
+ [Fact]
+ public void AdditionalSettings_Should_Be_Case_Insensitive()
+ {
+ // Arrange
+ var config = new FormatterConfiguration
+ {
+ AdditionalSettings = new Dictionary(System.StringComparer.OrdinalIgnoreCase)
+ {
+ { "MySetting", "myValue" }
+ }
+ };
+
+ // Act & Assert
+ config.AdditionalSettings["mysetting"].Should().Be("myValue");
+ config.AdditionalSettings["MYSETTING"].Should().Be("myValue");
+ }
+}
diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs
index 493788b06..114fd0dc6 100644
--- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs
+++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs
@@ -70,7 +70,7 @@ public void ExtractFormatters_Should_Extract_Known_Html_Formatter()
// Assert
result.Should().ContainKey("html");
- result["html"]["outputFilePath"].Should().Be("report.html");
+ result["html"].OutputFilePath.Should().Be("report.html");
}
[Fact]
@@ -88,7 +88,7 @@ public void ExtractFormatters_Should_Extract_Known_Message_Formatter()
// Assert
result.Should().ContainKey("message");
- result["message"]["outputFilePath"].Should().Be("messages.ndjson");
+ result["message"].OutputFilePath.Should().Be("messages.ndjson");
}
[Fact]
@@ -106,8 +106,8 @@ public void ExtractFormatters_Should_Extract_Custom_Formatter_Via_AdditionalForm
// Assert
result.Should().ContainKey("customFormatter");
- result["customFormatter"]["setting1"].Should().Be("value1");
- result["customFormatter"]["setting2"].Should().Be("value2");
+ result["customFormatter"].AdditionalSettings["setting1"].Should().Be("value1");
+ result["customFormatter"].AdditionalSettings["setting2"].Should().Be("value2");
}
[Fact]
@@ -147,8 +147,8 @@ public void ExtractFormatters_Should_Handle_Additional_Options_On_Known_Formatte
var result = FormattersConfigExtractor.ExtractFormatters(json);
// Assert
- result["html"]["outputFilePath"].Should().Be("report.html");
- result["html"]["customOption"].Should().Be("customValue");
+ result["html"].OutputFilePath.Should().Be("report.html");
+ result["html"].AdditionalSettings["customOption"].Should().Be("customValue");
}
[Fact]
@@ -165,8 +165,8 @@ public void ExtractFormatters_Should_Handle_Boolean_Config_Values()
var result = FormattersConfigExtractor.ExtractFormatters(json);
// Assert
- result["custom"]["enabled"].Should().Be(true);
- result["custom"]["debug"].Should().Be(false);
+ result["custom"].AdditionalSettings["enabled"].Should().Be(true);
+ result["custom"].AdditionalSettings["debug"].Should().Be(false);
}
[Fact]
@@ -183,8 +183,8 @@ public void ExtractFormatters_Should_Handle_Numeric_Config_Values()
var result = FormattersConfigExtractor.ExtractFormatters(json);
// Assert
- result["custom"]["timeout"].Should().Be(30.5);
- result["custom"]["retries"].Should().Be(3.0); // Numbers are parsed as double
+ result["custom"].AdditionalSettings["timeout"].Should().Be(30.5);
+ result["custom"].AdditionalSettings["retries"].Should().Be(3.0); // Numbers are parsed as double
}
[Fact]
@@ -224,7 +224,8 @@ public void ConvertFormattersElement_Should_Handle_Empty_Formatter_Options()
// Assert
result.Should().ContainKey("html");
- result["html"].Should().BeEmpty();
+ result["html"].OutputFilePath.Should().BeNull();
+ result["html"].AdditionalSettings.Should().BeEmpty();
}
[Fact]
@@ -242,7 +243,7 @@ public void ExtractFormatters_Should_Be_Case_Insensitive_For_Formatter_Names()
// Assert
// The key should be accessible case-insensitively due to StringComparer.OrdinalIgnoreCase
- result["html"]["outputFilePath"].Should().Be("report.html");
- result["HTML"]["outputFilePath"].Should().Be("report.html");
+ result["html"].OutputFilePath.Should().Be("report.html");
+ result["HTML"].OutputFilePath.Should().Be("report.html");
}
}
diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/JsonEnvironmentConfigurationResolverTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/JsonEnvironmentConfigurationResolverTests.cs
index 032a452b4..5b4b494e0 100644
--- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/JsonEnvironmentConfigurationResolverTests.cs
+++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/JsonEnvironmentConfigurationResolverTests.cs
@@ -54,7 +54,7 @@ public void Resolve_Should_Return_Configuration_From_Environment_Variables()
var result = _sut.Resolve();
// Assert
- result["formatter1"]["configSetting1"].Should().Be("configValue1");
+ result["formatter1"].AdditionalSettings["configSetting1"].Should().Be("configValue1");
}
@@ -78,7 +78,7 @@ public void Resolve_should_return_configuration_with_case_insensitive_formatter_
var result = _sut.Resolve();
// Assert
- result["formatter1"]["configSetting1"].Should().Be("configValue1");
+ result["formatter1"].AdditionalSettings["configSetting1"].Should().Be("configValue1");
}
[Fact]
@@ -103,8 +103,8 @@ public void Resolve_Should_Return_MultipleConfigurations_From_Environment_Variab
Assert.Equal(2, result.Count);
var first = result["html"];
var second = result["message"];
- Assert.Equal("forHtml", first["outputFilePath"]);
- Assert.Equal("forMessages", second["outputFilePath"]);
+ Assert.Equal("forHtml", first.OutputFilePath);
+ Assert.Equal("forMessages", second.OutputFilePath);
}
[Fact]
@@ -130,7 +130,7 @@ public void Resolve_Should_Parse_JSON_Format_Environment_Variable()
// Assert
result.Should().ContainKey("message");
- result["message"]["outputFilePath"].Should().Be("foo.ndjson");
+ result["message"].OutputFilePath.Should().Be("foo.ndjson");
result.Should().HaveCount(1);
}
}
\ No newline at end of file
diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/KeyValueEnvironmentConfigurationResolverTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/KeyValueEnvironmentConfigurationResolverTests.cs
index 3157c892b..638a40452 100644
--- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/KeyValueEnvironmentConfigurationResolverTests.cs
+++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/KeyValueEnvironmentConfigurationResolverTests.cs
@@ -92,7 +92,8 @@ public void Resolve_should_configure_single_formatter_with_true_setting()
// Assert
result.Should().HaveCount(1);
result.Should().ContainKey(SampleFormatterName)
- .WhoseValue.Should().BeEmpty();
+ .WhoseValue.OutputFilePath.Should().BeNull();
+ result[SampleFormatterName].AdditionalSettings.Should().BeEmpty();
}
[Fact]
@@ -153,7 +154,7 @@ public void Resolve_should_configure_single_formatter_with_output_file_settings(
// Assert
result.Should().HaveCount(1);
result.Should().ContainKey(SampleFormatterName)
- .WhoseValue.Should().Contain("outputFilePath", "foo.txt");
+ .WhoseValue.OutputFilePath.Should().Be("foo.txt");
}
[Fact]
@@ -174,7 +175,7 @@ public void Resolve_should_configure_single_formatter_with_case_insensitive_sett
// Assert
result.Should().HaveCount(1);
result.Should().ContainKey(SampleFormatterName)
- .WhoseValue.Should().Contain("outputFilePath", "foo.txt");
+ .WhoseValue.OutputFilePath.Should().Be("foo.txt");
}
[Fact]
@@ -194,8 +195,8 @@ public void Resolve_should_configure_single_formatter_with_multiple_settings()
// Assert
result.Should().HaveCount(1);
- result.Should().ContainKey(SampleFormatterName)
- .WhoseValue.Should().Contain("setting1", "value1")
+ result.Should().ContainKey(SampleFormatterName);
+ result[SampleFormatterName].AdditionalSettings.Should().Contain("setting1", "value1")
.And.Contain("setting2", "value2");
}
@@ -216,8 +217,8 @@ public void Resolve_should_trim_whitespaces_in_settings()
// Assert
result.Should().HaveCount(1);
- result.Should().ContainKey(SampleFormatterName)
- .WhoseValue.Should().Contain("setting1", "value1")
+ result.Should().ContainKey(SampleFormatterName);
+ result[SampleFormatterName].AdditionalSettings.Should().Contain("setting1", "value1")
.And.Contain("setting2", "value2");
}
@@ -240,8 +241,8 @@ public void Resolve_should_configure_multiple_formatters()
// Assert
result.Should().HaveCount(2);
result.Should().ContainKey("f1")
- .WhoseValue.Should().Contain("outputFilePath", "foo.txt");
+ .WhoseValue.OutputFilePath.Should().Be("foo.txt");
result.Should().ContainKey("f2")
- .WhoseValue.Should().Contain("outputFilePath", "bar.txt");
+ .WhoseValue.OutputFilePath.Should().Be("bar.txt");
}
}
\ No newline at end of file
diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/FileWritingFormatterBaseTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/FileWritingFormatterBaseTests.cs
index 89132f6f1..96e348ea8 100644
--- a/Tests/Reqnroll.RuntimeTests/Formatters/FileWritingFormatterBaseTests.cs
+++ b/Tests/Reqnroll.RuntimeTests/Formatters/FileWritingFormatterBaseTests.cs
@@ -78,11 +78,11 @@ protected override Stream CreateTargetFileStream(string outputPath)
if (ThrowOnCreateTargetFileStream) throw new System.Exception("fail");
return new MemoryStream();
}
- protected override void FinalizeInitialization(string outputPath, IDictionary formatterConfiguration, Action onInitialized)
+ protected override void FinalizeInitialization(string outputPath, FormatterConfiguration configuration, Action onInitialized)
{
FinalizeInitializationCalled = true;
if (ThrowOnFinalizeInitialization) throw new System.Exception("fail");
- base.FinalizeInitialization(outputPath, formatterConfiguration, onInitialized);
+ base.FinalizeInitialization(outputPath, configuration, onInitialized);
LastOutputPath = outputPath;
}
public async Task PostEnvelopeAsync(Envelope envelope)
@@ -90,9 +90,9 @@ public async Task PostEnvelopeAsync(Envelope envelope)
await PostedMessages.Writer.WriteAsync(envelope);
}
- public string TestConfiguredOutputFilePath(IDictionary formatterConfiguration)
+ public string TestGetOutputFilePath(FormatterConfiguration configuration)
{
- return ConfiguredOutputFilePath(formatterConfiguration);
+ return GetOutputFilePath(configuration);
}
}
@@ -112,7 +112,7 @@ public FileWritingFormatterBaseTests()
public void LaunchInner_InvalidPathCharacters_HandlesGracefully()
{
_fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny())).Returns(true);
- var config = new Dictionary { { "outputFilePath", "invalid\0path.txt" } };
+ var config = new FormatterConfiguration { OutputFilePath = "invalid\0path.txt" };
_sut.LaunchInner(config, enabled => enabled.Should().BeFalse());
_loggerMock.Verify(l => l.WriteMessage(It.Is(s => s.Contains( "is invalid or missing."))), Times.Once);
}
@@ -121,7 +121,7 @@ public void LaunchInner_InvalidPathCharacters_HandlesGracefully()
public void LaunchInner_EmptyFileName_UsesDefaultFileName()
{
_fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny())).Returns(true);
- var config = new Dictionary { { "outputFilePath", "somedir/" } };
+ var config = new FormatterConfiguration { OutputFilePath = "somedir/" };
_sut.LaunchInner(config, enabled => enabled.Should().BeTrue());
_sut.LastOutputPath.Should().EndWith("default.txt");
}
@@ -137,7 +137,7 @@ public void LaunchInner_InvalidFile_DisablesFormatter()
_fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny())).Returns(true);
_configMock.Setup(c => c.Enabled).Returns(true);
- var config = new Dictionary { { "outputFilePath", "invalid|file.txt" } };
+ var config = new FormatterConfiguration { OutputFilePath = "invalid|file.txt" };
_sut.LaunchInner(config, enabled => enabled.Should().BeFalse());
_loggerMock.Verify(l => l.WriteMessage(It.Is(s => s.Contains("invalid or missing"))), Times.Once);
}
@@ -147,7 +147,7 @@ public void LaunchInner_CreatesDirectoryIfNotExists()
{
_fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny())).Returns(false);
_configMock.Setup(c => c.Enabled).Returns(true);
- var config = new Dictionary { { "outputFilePath", "dir/file.txt" } };
+ var config = new FormatterConfiguration { OutputFilePath = "dir/file.txt" };
_sut.LaunchInner(config, _ => { });
_fileSystemMock.Verify(f => f.CreateDirectory(It.IsAny()), Times.Once);
}
@@ -157,7 +157,7 @@ public void LaunchInner_HandlesExceptionOnCreateDirectory()
{
_fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny())).Returns(false);
_fileSystemMock.Setup(f => f.CreateDirectory(It.IsAny())).Throws(new System.Exception("fail"));
- var config = new Dictionary { { "outputFilePath", "dir/file.txt" } };
+ var config = new FormatterConfiguration { OutputFilePath = "dir/file.txt" };
_sut.LaunchInner(config, enabled => enabled.Should().BeFalse());
_loggerMock.Verify(l => l.WriteMessage(It.Is(s => s.Contains("occurred creating the destination directory"))), Times.Once);
}
@@ -166,7 +166,7 @@ public void LaunchInner_HandlesExceptionOnCreateDirectory()
public void LaunchInner_ValidConfig_InitializesFileStream()
{
_fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny())).Returns(true);
- var config = new Dictionary { { "outputFilePath", "file.txt" } };
+ var config = new FormatterConfiguration { OutputFilePath = "file.txt" };
_sut.LaunchInner(config, enabled => enabled.Should().BeTrue());
_sut.OnTargetFileStreamInitializedCalled.Should().BeTrue();
_sut.LastOutputPath.Should().NotBeNull();
@@ -178,7 +178,7 @@ public async Task ConsumeAndFormatMessagesBackgroundTask_HandlesNullTargetFileSt
// Arrange: set a flag so that the SUT sets TargetFileStream to null during initialization
_sut.ThrowOnCreateTargetFileStream = true; // Use this flag to simulate failure and set TargetFileStream to null
- var config = new Dictionary { { "outputFilePath", "file.txt" } };
+ var config = new FormatterConfiguration { OutputFilePath = "file.txt" };
_sut.LaunchInner(config, _ => { });
// Act: invoke the background task
@@ -201,7 +201,7 @@ public async Task ConsumeAndFormatMessagesBackgroundTask_HandlesOperationCancele
{
// Arrange: set up a valid file stream and post a message
_fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny())).Returns(true);
- var config = new Dictionary { { "outputFilePath", "file.txt" } };
+ var config = new FormatterConfiguration { OutputFilePath = "file.txt" };
_sut.LaunchInner(config, _ => { });
var envelope = Envelope.Create(new TestRunStarted(new Io.Cucumber.Messages.Types.Timestamp(0, 0), ""));
await _sut.PostEnvelopeAsync(envelope);
@@ -229,7 +229,7 @@ public async Task ConsumeAndFormatMessagesBackgroundTask_HandlesOperationCancele
public void Dispose_CallsDisposeFileStreamAndBaseDispose()
{
_fileSystemMock.Setup(f => f.DirectoryExists(It.IsAny())).Returns(true);
- var config = new Dictionary { { "outputFilePath", "file.txt" } };
+ var config = new FormatterConfiguration { OutputFilePath = "file.txt" };
_sut.LaunchInner(config, _ => { });
_sut.Dispose();
_sut.OnTargetFileStreamDisposingCalled.Should().BeTrue();
@@ -240,7 +240,7 @@ public void LaunchFormatter_Should_Create_Local_Path_When_No_Path_Provided_in_Co
{
var sp = Path.DirectorySeparatorChar;
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin")).Returns(new Dictionary { { "outputFilePath", "aFileName.txt" } });
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin")).Returns(new FormatterConfiguration { OutputFilePath = "aFileName.txt" });
_fileSystemMock.Setup(fs => fs.DirectoryExists(It.IsAny())).Returns(true);
_sut.LaunchFormatter(new Mock().Object);
@@ -255,7 +255,7 @@ public void LaunchFormatter_Should_Apply_Default_Extension_When_Filename_Has_No_
{
var sp = Path.DirectorySeparatorChar;
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin")).Returns(new Dictionary { { "outputFilePath", "myoutput" } });
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin")).Returns(new FormatterConfiguration { OutputFilePath = "myoutput" });
_fileSystemMock.Setup(fs => fs.DirectoryExists(It.IsAny())).Returns(true);
_sut.LaunchFormatter(new Mock().Object);
@@ -270,7 +270,7 @@ public void LaunchFormatter_Should_Not_Apply_Default_Extension_When_Filename_Has
{
var sp = Path.DirectorySeparatorChar;
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin")).Returns(new Dictionary { { "outputFilePath", "myoutput.log" } });
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin")).Returns(new FormatterConfiguration { OutputFilePath = "myoutput.log" });
_fileSystemMock.Setup(fs => fs.DirectoryExists(It.IsAny())).Returns(true);
_sut.LaunchFormatter(new Mock().Object);
@@ -284,8 +284,8 @@ public void LaunchFormatter_Should_Not_Apply_Default_Extension_When_Filename_Has
public async Task PublishAsync_Should_Write_Envelopes()
{
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin"))
- .Returns(new Dictionary { { "outputFilePath", @"C:\/valid\/path/output.txt" } });
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin"))
+ .Returns(new FormatterConfiguration { OutputFilePath = @"C:\/valid\/path/output.txt" });
_fileSystemMock.Setup(fs => fs.DirectoryExists(It.IsAny())).Returns(true);
var message = Envelope.Create(new TestRunStarted(new Io.Cucumber.Messages.Types.Timestamp(1, 0), "started"));
@@ -301,8 +301,8 @@ public async Task PublishAsync_Should_Write_Envelopes()
public void LaunchFormatter_Should_Create_Directory_If_Not_Exists()
{
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin"))
- .Returns(new Dictionary { { "outputFilePath", "outputFilePath" } });
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin"))
+ .Returns(new FormatterConfiguration { OutputFilePath = "outputFilePath" });
_fileSystemMock.Setup(fs => fs.DirectoryExists(It.IsAny())).Returns(false);
_sut.LaunchFormatter(new Mock().Object);
@@ -315,8 +315,8 @@ public void LaunchFormatter_Should_Create_Directory_If_Not_Exists()
public async Task Publish_FollowedBy_Dispose_Should_Cause_CancelToken_to_Fire()
{
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin"))
- .Returns(new Dictionary { { "outputFilePath", @"C:\/valid\/path/output.txt" } });
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin"))
+ .Returns(new FormatterConfiguration { OutputFilePath = @"C:\/valid\/path/output.txt" });
_fileSystemMock.Setup(fs => fs.DirectoryExists(It.IsAny())).Returns(true);
var message = Envelope.Create(new TestRunStarted(new Io.Cucumber.Messages.Types.Timestamp(1, 0), "started"));
@@ -330,18 +330,25 @@ public async Task Publish_FollowedBy_Dispose_Should_Cause_CancelToken_to_Fire()
}
[Fact]
- public void ConfiguredOutputFilePath_MissingKey_ReturnsEmptyString()
+ public void GetOutputFilePath_NullConfiguration_ReturnsEmptyString()
{
- var config = new Dictionary();
- var result = _sut.TestConfiguredOutputFilePath(config);
+ var result = _sut.TestGetOutputFilePath(null!);
result.Should().BeEmpty();
}
[Fact]
- public void ConfiguredOutputFilePath_NullValue_ReturnsEmptyString()
+ public void GetOutputFilePath_NullOutputFilePath_ReturnsEmptyString()
{
- var config = new Dictionary { { "outputFilePath", null! } };
- var result = _sut.TestConfiguredOutputFilePath(config);
+ var config = new FormatterConfiguration { OutputFilePath = null };
+ var result = _sut.TestGetOutputFilePath(config);
result.Should().BeEmpty();
}
+
+ [Fact]
+ public void GetOutputFilePath_ValidOutputFilePath_ReturnsPath()
+ {
+ var config = new FormatterConfiguration { OutputFilePath = "test/path.txt" };
+ var result = _sut.TestGetOutputFilePath(config);
+ result.Should().Be("test/path.txt");
+ }
}
diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/FormatterBaseTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/FormatterBaseTests.cs
index 9306179c2..fe6e20696 100644
--- a/Tests/Reqnroll.RuntimeTests/Formatters/FormatterBaseTests.cs
+++ b/Tests/Reqnroll.RuntimeTests/Formatters/FormatterBaseTests.cs
@@ -20,7 +20,7 @@ public class FormatterBaseTests
private class TestFormatter : FormatterBase
{
public bool LaunchInnerCalled = false;
- public IDictionary LaunchInnerConfig = null!;
+ public FormatterConfiguration? LaunchInnerConfig = null;
public Action LaunchInnerCallback = null!;
public bool ConsumeAndFormatMessagesCalled = false;
public CancellationToken? ConsumedToken;
@@ -32,10 +32,10 @@ private class TestFormatter : FormatterBase
public TestFormatter(IFormattersConfigurationProvider config, IFormatterLog logger, string name)
: base(config, logger, name) { }
- public override void LaunchInner(IDictionary formatterConfig, Action onAfterInitialization)
+ public override void LaunchInner(FormatterConfiguration configuration, Action onAfterInitialization)
{
LaunchInnerCalled = true;
- LaunchInnerConfig = formatterConfig;
+ LaunchInnerConfig = configuration;
LaunchInnerCallback = onAfterInitialization;
if (CompleteWriterOnLaunchInner)
PostedMessages.Writer.Complete();
@@ -82,7 +82,7 @@ public void LaunchFormatter_Disabled_ReportsInitializedFalse()
public void LaunchFormatter_Enabled_Calls_LaunchInner_And_StartsTask()
{
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin")).Returns(new Dictionary());
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin")).Returns(new FormatterConfiguration());
_sut.LaunchFormatter(_brokerMock.Object);
_sut.LaunchInnerCalled.Should().BeTrue();
_sut.LaunchInnerConfig.Should().NotBeNull();
@@ -93,7 +93,7 @@ public void LaunchFormatter_Enabled_Calls_LaunchInner_And_StartsTask()
public async Task PublishAsync_Writes_Message_And_Closes_On_TestRunFinished()
{
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin")).Returns(new Dictionary());
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin")).Returns(new FormatterConfiguration());
_sut.LaunchFormatter(_brokerMock.Object);
var msg = Envelope.Create(new TestRunFinished("", false, new Timestamp(0, 0), null, ""));
await _sut.PublishAsync(msg);
@@ -113,7 +113,7 @@ public async Task PublishAsync_Does_Not_Write_When_Closed()
public async Task CloseAsync_Throws_If_Already_Closed()
{
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin")).Returns(new Dictionary());
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin")).Returns(new FormatterConfiguration());
_sut.LaunchFormatter(_brokerMock.Object);
await _sut.PublishAsync(Envelope.Create(new TestRunStarted(new Timestamp(0, 0), "")));
await _sut.CloseAsync();
@@ -124,7 +124,7 @@ public async Task CloseAsync_Throws_If_Already_Closed()
public void Dispose_Waits_For_FormatterTask_And_DumpsMessages()
{
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin")).Returns(new Dictionary());
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin")).Returns(new FormatterConfiguration());
_sut.LaunchFormatter(_brokerMock.Object);
_sut.Dispose();
_loggerMock.Verify(l => l.DumpMessages(), Times.Once);
@@ -134,7 +134,7 @@ public void Dispose_Waits_For_FormatterTask_And_DumpsMessages()
public void LaunchFormatter_NoConfigEntry_ReportsInitializedFalse()
{
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin")).Returns((IDictionary)null!);
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin")).Returns((FormatterConfiguration)null!);
_sut.LaunchFormatter(_brokerMock.Object);
_loggerMock.Verify(l => l.WriteMessage(It.Is(s => s.Contains("disabled via configuration"))), Times.Once);
_brokerMock.Verify(b => b.FormatterInitialized(_sut, false), Times.Once);
@@ -144,11 +144,11 @@ public void LaunchFormatter_NoConfigEntry_ReportsInitializedFalse()
public void LaunchFormatter_EmptyConfigEntry_ReportsInitializedTrue()
{
_configMock.Setup(c => c.Enabled).Returns(true);
- _configMock.Setup(c => c.GetFormatterConfigurationByName("testPlugin")).Returns(new Dictionary());
+ _configMock.Setup(c => c.GetFormatterConfiguration("testPlugin")).Returns(new FormatterConfiguration());
_sut.LaunchFormatter(_brokerMock.Object);
_sut.LaunchInnerCalled.Should().BeTrue();
_sut.LaunchInnerConfig.Should().NotBeNull();
- _sut.LaunchInnerConfig.Should().BeEmpty();
+ _sut.LaunchInnerConfig!.AdditionalSettings.Should().BeEmpty();
_sut.LaunchInnerCallback.Should().NotBeNull();
_brokerMock.Verify(b => b.FormatterInitialized(_sut, true), Times.Once);
From 2447b428f03b1a3f3161053deea8cb4eab0d459e Mon Sep 17 00:00:00 2001
From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com>
Date: Tue, 3 Feb 2026 14:04:06 -0600
Subject: [PATCH 3/5] Update CHANGELOG.md
---
CHANGELOG.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7085117ec..3b75c0937 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,10 @@
# [vNext]
## Improvements:
-
+* Refactored Formatters Configuration deserialization to align with Reqnroll JsonConfig. Created typed configuration settings object. Breaking change for custom Formatter implementers.
## Bug fixes:
-*Contributors of this release (in alphabetical order):*
+*Contributors of this release (in alphabetical order):* @clrudolphi
# v3.3.3 - 2026-01-27
From 54919220442aa002bb1fb825650731ba583bda56 Mon Sep 17 00:00:00 2001
From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com>
Date: Mon, 23 Mar 2026 16:30:02 -0500
Subject: [PATCH 4/5] Modified per review comments. Removed old property rather
than mark as [Obsolete]
---
.../JsonConfig/FormattersElement.cs | 2 +-
.../FormattersConfigExtractor.cs | 39 +------------------
.../FormattersConfigurationProvider.cs | 8 ----
.../IFormattersConfigurationProvider.cs | 9 -----
4 files changed, 3 insertions(+), 55 deletions(-)
diff --git a/Reqnroll/Configuration/JsonConfig/FormattersElement.cs b/Reqnroll/Configuration/JsonConfig/FormattersElement.cs
index d1db75c64..6cdd154fd 100644
--- a/Reqnroll/Configuration/JsonConfig/FormattersElement.cs
+++ b/Reqnroll/Configuration/JsonConfig/FormattersElement.cs
@@ -16,6 +16,6 @@ public class FormattersElement
/// Captures any additional/custom formatters not explicitly defined above.
///
[JsonExtensionData]
- public Dictionary AdditionalFormatters { get; set; }
+ public IDictionary AdditionalFormatters { get; set; }
}
}
diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs b/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
index 27844c972..4ce05a039 100644
--- a/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
+++ b/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
@@ -57,21 +57,11 @@ public static IDictionary ConvertFormattersEleme
// Process additional/custom formatters captured by JsonExtensionData
if (formatters.AdditionalFormatters != null)
- {
foreach (var kvp in formatters.AdditionalFormatters)
{
- if (kvp.Value.ValueKind == JsonValueKind.Object)
- {
- result[kvp.Key] = ConvertJsonElementToFormatterConfiguration(kvp.Value);
- }
- else
- {
- // Non-object values get an empty config
- result[kvp.Key] = new FormatterConfiguration();
- }
+ result[kvp.Key] = ConvertFormatterOptions(kvp.Value.Deserialize());
}
- }
-
+
return result;
}
@@ -84,7 +74,6 @@ private static FormatterConfiguration ConvertFormatterOptions(FormatterOptionsEl
// Process additional options captured by JsonExtensionData
if (options.AdditionalOptions != null)
- {
foreach (var kvp in options.AdditionalOptions)
{
var value = GetConfigValue(kvp.Value);
@@ -93,30 +82,6 @@ private static FormatterConfiguration ConvertFormatterOptions(FormatterOptionsEl
config.AdditionalSettings[kvp.Key] = value;
}
}
- }
-
- return config;
- }
-
- private static FormatterConfiguration ConvertJsonElementToFormatterConfiguration(JsonElement element)
- {
- var config = new FormatterConfiguration();
-
- foreach (var property in element.EnumerateObject())
- {
- if (string.Equals(property.Name, "outputFilePath", StringComparison.OrdinalIgnoreCase))
- {
- config.OutputFilePath = property.Value.GetString();
- }
- else
- {
- var value = GetConfigValue(property.Value);
- if (value != null)
- {
- config.AdditionalSettings[property.Name] = value;
- }
- }
- }
return config;
}
diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs b/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs
index d199cf72c..f26db37a2 100644
--- a/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs
+++ b/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs
@@ -40,14 +40,6 @@ public FormatterConfiguration GetFormatterConfiguration(string formatterName)
return null;
}
- ///
- [Obsolete("Use GetFormatterConfiguration instead for type-safe access to configuration values.")]
- public IDictionary GetFormatterConfigurationByName(string formatterName)
- {
- var config = GetFormatterConfiguration(formatterName);
- return config?.ToDictionary();
- }
-
private FormattersConfiguration ResolveConfiguration()
{
var combinedConfig = new Dictionary(StringComparer.OrdinalIgnoreCase);
diff --git a/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs b/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs
index f0e457326..f63c663f3 100644
--- a/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs
+++ b/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs
@@ -15,14 +15,5 @@ public interface IFormattersConfigurationProvider
/// The FormatterConfiguration, or null if the formatter is not configured.
FormatterConfiguration GetFormatterConfiguration(string formatterName);
- ///
- /// Gets the configuration for a formatter by name as a dictionary.
- ///
- /// The name of the formatter.
- /// The configuration dictionary, or null if the formatter is not configured.
- [Obsolete("Use GetFormatterConfiguration instead for type-safe access to configuration values.")]
- [EditorBrowsable(EditorBrowsableState.Never)]
- IDictionary GetFormatterConfigurationByName(string formatterName);
-
string ResolveTemplatePlaceholders(string template);
}
\ No newline at end of file
From 5735eaef2cb37144c8238b03d2cb684c7977368a Mon Sep 17 00:00:00 2001
From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com>
Date: Tue, 24 Mar 2026 14:07:59 -0500
Subject: [PATCH 5/5] Fully integrated Formatter configuration with Reqnroll
Configuration subsystem.
---
Reqnroll/Configuration/ConfigDefaults.cs | 3 +
Reqnroll/Configuration/ConfigurationLoader.cs | 6 +-
.../FormattersConfigurationSourceGenerator.cs | 15 ++
.../JsonConfig/FormattersElement.cs | 32 +--
.../Configuration/JsonConfig/JsonConfig.cs | 2 +-
.../JsonConfig/JsonConfigurationLoader.cs | 13 +-
.../JsonConfigurationSourceGenerator.cs | 2 -
.../Configuration/ReqnrollConfiguration.cs | 11 +-
.../FileBasedConfigurationResolver.cs | 106 --------
.../FormattersConfigExtractor.cs | 60 ++---
.../FormattersConfigurationProvider.cs | 4 +-
.../IFileBasedConfigurationResolver.cs | 3 -
.../IReqnrollConfigConfigurationResolver.cs | 3 +
.../ReqnrollConfigConfigurationResolver.cs | 38 +++
.../DefaultDependencyProvider.cs | 2 +-
.../MessagesCompatibilityTestBase.cs | 7 +-
.../Bindings/CultureInfoScopeTests.cs | 2 +-
.../Configuration/JsonConfigTests.cs | 36 +--
.../CucumberConfigurationTests.cs | 4 +-
.../FileBasedConfigurationResolverTests.cs | 137 ++++------
.../FormattersConfigExtractorTests.cs | 249 ++++--------------
.../ReqnrollOutputHelperTests.cs | 2 +-
22 files changed, 263 insertions(+), 474 deletions(-)
create mode 100644 Reqnroll/Configuration/JsonConfig/FormattersConfigurationSourceGenerator.cs
delete mode 100644 Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
delete mode 100644 Reqnroll/Formatters/Configuration/IFileBasedConfigurationResolver.cs
create mode 100644 Reqnroll/Formatters/Configuration/IReqnrollConfigConfigurationResolver.cs
create mode 100644 Reqnroll/Formatters/Configuration/ReqnrollConfigConfigurationResolver.cs
diff --git a/Reqnroll/Configuration/ConfigDefaults.cs b/Reqnroll/Configuration/ConfigDefaults.cs
index 383a191aa..acdecec15 100644
--- a/Reqnroll/Configuration/ConfigDefaults.cs
+++ b/Reqnroll/Configuration/ConfigDefaults.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using Reqnroll.BindingSkeletons;
+using Reqnroll.Formatters.Configuration;
namespace Reqnroll.Configuration
{
@@ -30,6 +32,7 @@ public static class ConfigDefaults
public const bool DisableFriendlyTestNames = false;
public static readonly string[] AddNonParallelizableMarkerForTags = Array.Empty();
+ public static Dictionary Formatters => new Dictionary(StringComparer.OrdinalIgnoreCase);
}
// ReSharper restore RedundantNameQualifier
}
\ No newline at end of file
diff --git a/Reqnroll/Configuration/ConfigurationLoader.cs b/Reqnroll/Configuration/ConfigurationLoader.cs
index dde376f32..5443019fe 100644
--- a/Reqnroll/Configuration/ConfigurationLoader.cs
+++ b/Reqnroll/Configuration/ConfigurationLoader.cs
@@ -4,6 +4,7 @@
using System.IO;
using Reqnroll.BindingSkeletons;
using Reqnroll.Configuration.JsonConfig;
+using Reqnroll.Formatters.Configuration;
using Reqnroll.PlatformCompatibility;
using Reqnroll.Tracing;
@@ -50,6 +51,8 @@ public ConfigurationLoader(IReqnrollJsonLocator reqnrollJsonLocator)
public static bool DefaultColoredOutput => ConfigDefaults.ColoredOutput;
+ private static Dictionary DefaultFormatters => ConfigDefaults.Formatters;
+
public bool HasJsonConfig
{
get
@@ -132,7 +135,8 @@ public static ReqnrollConfiguration GetDefault()
DefaultAddNonParallelizableMarkerForTags,
DefaultDisableFriendlyTestNames,
DefaultObsoleteBehavior,
- DefaultColoredOutput
+ DefaultColoredOutput,
+ DefaultFormatters
);
}
diff --git a/Reqnroll/Configuration/JsonConfig/FormattersConfigurationSourceGenerator.cs b/Reqnroll/Configuration/JsonConfig/FormattersConfigurationSourceGenerator.cs
new file mode 100644
index 000000000..b5066f212
--- /dev/null
+++ b/Reqnroll/Configuration/JsonConfig/FormattersConfigurationSourceGenerator.cs
@@ -0,0 +1,15 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Reqnroll.Configuration.JsonConfig;
+
+[JsonSourceGenerationOptions(WriteIndented = true,
+ PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified, // We specifiy the names explicitly
+ PropertyNameCaseInsensitive = true, // old custom parser supported ordinal ignore case, so we should do
+ UseStringEnumConverter = true, // use strings instead of numbers for enums
+ ReadCommentHandling = JsonCommentHandling.Skip)] // the user can comment his used configuration value
+[JsonSerializable(typeof(FormattersElement))]
+internal partial class FormattersConfigurationSourceGenerator : JsonSerializerContext
+{
+
+}
diff --git a/Reqnroll/Configuration/JsonConfig/FormattersElement.cs b/Reqnroll/Configuration/JsonConfig/FormattersElement.cs
index 6cdd154fd..38b3ddd94 100644
--- a/Reqnroll/Configuration/JsonConfig/FormattersElement.cs
+++ b/Reqnroll/Configuration/JsonConfig/FormattersElement.cs
@@ -1,21 +1,21 @@
using System.Collections.Generic;
-using System.Text.Json;
using System.Text.Json.Serialization;
-namespace Reqnroll.Configuration.JsonConfig
-{
- public class FormattersElement
- {
- [JsonPropertyName("html")]
- public FormatterOptionsElement Html { get; set; }
-
- [JsonPropertyName("message")]
- public FormatterOptionsElement Message { get; set; }
+namespace Reqnroll.Configuration.JsonConfig;
- ///
- /// Captures any additional/custom formatters not explicitly defined above.
- ///
- [JsonExtensionData]
- public IDictionary AdditionalFormatters { get; set; }
- }
+///
+/// Represents a collection of formatter configuration options, keyed by formatter name.
+///
+/// This json configuration element is used when overriding a formatter/s configuration by environment variable.
+/// The json should be structured as:
+/// { "formatters":
+/// { "myformatter":
+/// "settingKey": "settingValue"
+/// }
+/// }
+///
+public class FormattersElement
+{
+ [JsonPropertyName("formatters")]
+ public IDictionary Formatters { get; set; }
}
diff --git a/Reqnroll/Configuration/JsonConfig/JsonConfig.cs b/Reqnroll/Configuration/JsonConfig/JsonConfig.cs
index 990f22241..2faddbb6c 100644
--- a/Reqnroll/Configuration/JsonConfig/JsonConfig.cs
+++ b/Reqnroll/Configuration/JsonConfig/JsonConfig.cs
@@ -29,6 +29,6 @@ public class JsonConfig
public List BindingAssemblies { get; set; }
[JsonPropertyName("formatters")]
- public FormattersElement Formatters { get; set; }
+ public IDictionary Formatters { get; set; }
}
}
\ No newline at end of file
diff --git a/Reqnroll/Configuration/JsonConfig/JsonConfigurationLoader.cs b/Reqnroll/Configuration/JsonConfig/JsonConfigurationLoader.cs
index 871ef39d8..e954e4461 100644
--- a/Reqnroll/Configuration/JsonConfig/JsonConfigurationLoader.cs
+++ b/Reqnroll/Configuration/JsonConfig/JsonConfigurationLoader.cs
@@ -1,4 +1,6 @@
+using Reqnroll.Formatters.Configuration;
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
@@ -32,6 +34,7 @@ public ReqnrollConfiguration LoadJson(ReqnrollConfiguration reqnrollConfiguratio
var addNonParallelizableMarkerForTags = reqnrollConfiguration.AddNonParallelizableMarkerForTags;
bool disableFriendlyTestNames = reqnrollConfiguration.DisableFriendlyTestNames;
var obsoleteBehavior = reqnrollConfiguration.ObsoleteBehavior;
+ var formatters = new Dictionary(reqnrollConfiguration.Formatters, StringComparer.OrdinalIgnoreCase);
if (jsonConfig.Language != null)
{
@@ -106,6 +109,13 @@ public ReqnrollConfiguration LoadJson(ReqnrollConfiguration reqnrollConfiguratio
}
}
+ if (jsonConfig.Formatters != null)
+ {
+ foreach (var formatterEntry in jsonConfig.Formatters)
+ {
+ formatters[formatterEntry.Key] = FormattersConfigExtractor.ConvertFormatterOptions(formatterEntry.Value);
+ }
+ }
return new ReqnrollConfiguration(
ConfigSource.Json,
containerRegistrationCollection,
@@ -124,7 +134,8 @@ public ReqnrollConfiguration LoadJson(ReqnrollConfiguration reqnrollConfiguratio
addNonParallelizableMarkerForTags,
disableFriendlyTestNames,
obsoleteBehavior,
- coloredOutput
+ coloredOutput,
+ formatters
)
{
ConfigSourceText = jsonContent
diff --git a/Reqnroll/Configuration/JsonConfig/JsonConfigurationSourceGenerator.cs b/Reqnroll/Configuration/JsonConfig/JsonConfigurationSourceGenerator.cs
index 9f41b355e..944cd6475 100644
--- a/Reqnroll/Configuration/JsonConfig/JsonConfigurationSourceGenerator.cs
+++ b/Reqnroll/Configuration/JsonConfig/JsonConfigurationSourceGenerator.cs
@@ -10,8 +10,6 @@ namespace Reqnroll.Configuration.JsonConfig;
Converters = [typeof(CustomTimeSpanConverter)],
ReadCommentHandling = JsonCommentHandling.Skip)] // the user can comment his used configuration value
[JsonSerializable(typeof(JsonConfig))]
-[JsonSerializable(typeof(FormattersElement))]
-[JsonSerializable(typeof(FormatterOptionsElement))]
internal partial class JsonConfigurationSourceGenerator : JsonSerializerContext
{
diff --git a/Reqnroll/Configuration/ReqnrollConfiguration.cs b/Reqnroll/Configuration/ReqnrollConfiguration.cs
index 4391923de..d11f1b6fd 100644
--- a/Reqnroll/Configuration/ReqnrollConfiguration.cs
+++ b/Reqnroll/Configuration/ReqnrollConfiguration.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.Linq;
using Reqnroll.BindingSkeletons;
+using Reqnroll.Formatters.Configuration;
namespace Reqnroll.Configuration
{
@@ -33,7 +34,8 @@ public ReqnrollConfiguration(ConfigSource configSource,
string[] addNonParallelizableMarkerForTags,
bool disableFriendlyTestNames,
ObsoleteBehavior obsoleteBehavior,
- bool coloredOutput
+ bool coloredOutput,
+ IDictionary formatters
)
{
ConfigSource = configSource;
@@ -54,6 +56,7 @@ bool coloredOutput
DisableFriendlyTestNames = disableFriendlyTestNames;
ObsoleteBehavior = obsoleteBehavior;
ColoredOutput = coloredOutput;
+ Formatters = formatters ?? new Dictionary(StringComparer.OrdinalIgnoreCase);
}
public ConfigSource ConfigSource { get; set; }
@@ -86,6 +89,8 @@ bool coloredOutput
public List AdditionalStepAssemblies { get; set; }
+ public IDictionary Formatters { get; set; }
+
protected bool Equals(ReqnrollConfiguration other) => ConfigSource == other.ConfigSource
&& Equals(CustomDependencies, other.CustomDependencies)
&& Equals(GeneratorCustomDependencies, other.GeneratorCustomDependencies)
@@ -102,7 +107,8 @@ protected bool Equals(ReqnrollConfiguration other) => ConfigSource == other.Conf
&& StepDefinitionSkeletonStyle == other.StepDefinitionSkeletonStyle
&& AdditionalStepAssemblies.SequenceEqual(other.AdditionalStepAssemblies)
&& AddNonParallelizableMarkerForTags.SequenceEqual(other.AddNonParallelizableMarkerForTags)
- && DisableFriendlyTestNames == other.DisableFriendlyTestNames;
+ && DisableFriendlyTestNames == other.DisableFriendlyTestNames
+ && Formatters == other.Formatters;
public override bool Equals(object obj)
{
@@ -145,6 +151,7 @@ public override int GetHashCode()
hashCode = (hashCode * 397) ^ (AdditionalStepAssemblies != null ? AdditionalStepAssemblies.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ (AddNonParallelizableMarkerForTags != null ? AddNonParallelizableMarkerForTags.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ DisableFriendlyTestNames.GetHashCode();
+ hashCode = (hashCode * 397) ^ (Formatters != null ? Formatters.GetHashCode() : 0);
return hashCode;
}
}
diff --git a/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
deleted file mode 100644
index c37e24c5d..000000000
--- a/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-using Reqnroll.Analytics.UserId;
-using Reqnroll.Configuration;
-using Reqnroll.Formatters.RuntimeSupport;
-using Reqnroll.Utils;
-using System;
-using System.Collections.Generic;
-using System.Text.Json;
-
-namespace Reqnroll.Formatters.Configuration;
-
-public class FileBasedConfigurationResolver : IFileBasedConfigurationResolver
-{
- private readonly IReqnrollJsonLocator _configFileLocator;
- private readonly IFileSystem _fileSystem;
- private readonly IFileService _fileService;
- private readonly IFormatterLog _log;
-
- public FileBasedConfigurationResolver(
- IReqnrollJsonLocator configurationFileLocator,
- IFileSystem fileSystem,
- IFileService fileService,
- IFormatterLog log = null)
- {
- _configFileLocator = configurationFileLocator;
- _fileSystem = fileSystem;
- _fileService = fileService;
- _log = log;
- }
-
- ///
- /// File-based configuration replaces entirely (does not merge with previous settings).
- ///
- public bool ShouldMergeSettings => false;
-
- public IDictionary Resolve()
- {
- var jsonContent = GetJsonContent();
- if (jsonContent == null)
- return new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- try
- {
- return FormattersConfigExtractor.ExtractFormatters(jsonContent);
- }
- catch (JsonException ex)
- {
- _log?.WriteMessage($"Failed to parse formatters configuration: {ex.Message}");
- return new Dictionary(StringComparer.OrdinalIgnoreCase);
- }
- }
-
- private string GetJsonContent()
- {
- try
- {
- string fileName;
- try
- {
- fileName = _configFileLocator.GetReqnrollJsonFilePath();
- }
- catch (Exception ex)
- {
- _log?.WriteMessage($"Failed to locate Reqnroll JSON file: {ex.Message}");
- return null;
- }
-
- if (string.IsNullOrWhiteSpace(fileName))
- {
- _log?.WriteMessage("Reqnroll JSON file path is empty");
- return null;
- }
-
- if (!_fileSystem.FileExists(fileName))
- {
- // This is not necessarily an error, could be a new project without a config file yet
- _log?.WriteMessage($"Reqnroll JSON file not found at: {fileName}");
- return null;
- }
-
- string jsonFileContent;
- try
- {
- jsonFileContent = _fileService.ReadAllText(fileName);
- }
- catch (Exception ex)
- {
- _log?.WriteMessage($"Failed to read Reqnroll JSON file '{fileName}': {ex.Message}");
- return null;
- }
-
- if (string.IsNullOrWhiteSpace(jsonFileContent))
- {
- _log?.WriteMessage($"Reqnroll JSON file '{fileName}' is empty");
- return null;
- }
-
- return jsonFileContent;
- }
- catch (Exception ex)
- {
- // Catch any other unexpected exceptions
- _log?.WriteMessage($"Unexpected error processing configuration file: {ex.Message}");
- return null;
- }
- }
-}
\ No newline at end of file
diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs b/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
index 4ce05a039..56982448d 100644
--- a/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
+++ b/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs
@@ -6,8 +6,7 @@
namespace Reqnroll.Formatters.Configuration;
///
-/// Utility class for extracting formatters configuration from JSON content
-/// using the centralized JsonConfig deserialization.
+/// Utility class for extracting formatters configuration from JSON content.
///
public static class FormattersConfigExtractor
{
@@ -23,8 +22,8 @@ public static IDictionary ExtractFormatters(stri
try
{
- var jsonConfig = JsonSerializer.Deserialize(jsonContent, JsonConfigurationSourceGenerator.Default.JsonConfig);
- return ConvertFormattersElement(jsonConfig?.Formatters);
+ var jsonConfig = JsonSerializer.Deserialize(jsonContent, FormattersConfigurationSourceGenerator.Default.FormattersElement);
+ return ConvertFormattersElement(jsonConfig);
}
catch (JsonException)
{
@@ -44,29 +43,20 @@ public static IDictionary ConvertFormattersEleme
if (formatters == null)
return result;
- // Process known formatters
- if (formatters.Html != null)
- {
- result["html"] = ConvertFormatterOptions(formatters.Html);
- }
-
- if (formatters.Message != null)
- {
- result["message"] = ConvertFormatterOptions(formatters.Message);
- }
-
- // Process additional/custom formatters captured by JsonExtensionData
- if (formatters.AdditionalFormatters != null)
- foreach (var kvp in formatters.AdditionalFormatters)
+ if (formatters.Formatters != null)
+ foreach (var kvp in formatters.Formatters)
{
- result[kvp.Key] = ConvertFormatterOptions(kvp.Value.Deserialize());
+ result[kvp.Key] = ConvertFormatterOptions(kvp.Value);
}
return result;
}
- private static FormatterConfiguration ConvertFormatterOptions(FormatterOptionsElement options)
+ internal static FormatterConfiguration ConvertFormatterOptions(FormatterOptionsElement options)
{
+ if (options == null)
+ return new FormatterConfiguration();
+
var config = new FormatterConfiguration
{
OutputFilePath = options.OutputFilePath
@@ -76,7 +66,7 @@ private static FormatterConfiguration ConvertFormatterOptions(FormatterOptionsEl
if (options.AdditionalOptions != null)
foreach (var kvp in options.AdditionalOptions)
{
- var value = GetConfigValue(kvp.Value);
+ var value = ConvertJsonElement(kvp.Value);
if (value != null)
{
config.AdditionalSettings[kvp.Key] = value;
@@ -86,22 +76,32 @@ private static FormatterConfiguration ConvertFormatterOptions(FormatterOptionsEl
return config;
}
- private static object GetConfigValue(JsonElement valueElement)
+ private static object ConvertJsonElement(JsonElement element)
{
- switch (valueElement.ValueKind)
+ switch (element.ValueKind)
{
case JsonValueKind.String:
- return valueElement.GetString();
- case JsonValueKind.False:
- case JsonValueKind.True:
- return valueElement.GetBoolean();
+ return element.GetString();
case JsonValueKind.Number:
- return valueElement.GetDouble();
+ return element.TryGetInt64(out var l) ? (object)l : element.GetDouble();
+ case JsonValueKind.True:
+ case JsonValueKind.False:
+ return element.GetBoolean();
+ case JsonValueKind.Object:
+ var dict = new Dictionary();
+ foreach (var prop in element.EnumerateObject())
+ dict[prop.Name] = ConvertJsonElement(prop.Value);
+ return dict;
+ case JsonValueKind.Array:
+ var list = new List