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(); + foreach (var item in element.EnumerateArray()) + list.Add(ConvertJsonElement(item)); + return list; case JsonValueKind.Null: + case JsonValueKind.Undefined: return null; default: - // For embedded JSON objects or arrays, keep as JsonElement - return valueElement; + throw new ArgumentOutOfRangeException($"Unexpected JsonElement.ValueKind: {element.ValueKind}. Formatter configuration only supports strings, numbers, booleans, null, nested objects and arrays of the above."); } } } diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs b/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs index f26db37a2..69ba8218c 100644 --- a/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs +++ b/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs @@ -11,7 +11,7 @@ namespace Reqnroll.Formatters.Configuration; /// When any consumer of this class asks for one of the properties of , /// the class will resolve the configuration (only once). /// -/// One or more profiles may be read from the configuration file () +/// One or more profiles may be read from the configuration file () /// then environment variable overrides are applied (first , then ). /// public class FormattersConfigurationProvider : IFormattersConfigurationProvider @@ -23,7 +23,7 @@ public class FormattersConfigurationProvider : IFormattersConfigurationProvider public bool Enabled => _resolvedConfiguration.Value.Enabled; - public FormattersConfigurationProvider(IFileBasedConfigurationResolver fileBasedConfigurationResolver, IJsonEnvironmentConfigurationResolver jsonEnvironmentConfigurationResolver, IKeyValueEnvironmentConfigurationResolver keyValueEnvironmentConfigurationResolver, IFormattersConfigurationDisableOverrideProvider envVariableDisableFlagProvider, IVariableSubstitutionService variableSubstitutionService) + public FormattersConfigurationProvider(IReqnrollConfigConfigurationResolver fileBasedConfigurationResolver, IJsonEnvironmentConfigurationResolver jsonEnvironmentConfigurationResolver, IKeyValueEnvironmentConfigurationResolver keyValueEnvironmentConfigurationResolver, IFormattersConfigurationDisableOverrideProvider envVariableDisableFlagProvider, IVariableSubstitutionService variableSubstitutionService) { _resolvers = [fileBasedConfigurationResolver, jsonEnvironmentConfigurationResolver, keyValueEnvironmentConfigurationResolver]; _resolvedConfiguration = new Lazy(ResolveConfiguration); diff --git a/Reqnroll/Formatters/Configuration/IFileBasedConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/IFileBasedConfigurationResolver.cs deleted file mode 100644 index 189abd1f3..000000000 --- a/Reqnroll/Formatters/Configuration/IFileBasedConfigurationResolver.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Reqnroll.Formatters.Configuration; - -public interface IFileBasedConfigurationResolver : IFormattersConfigurationResolverBase; diff --git a/Reqnroll/Formatters/Configuration/IReqnrollConfigConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/IReqnrollConfigConfigurationResolver.cs new file mode 100644 index 000000000..c90b24886 --- /dev/null +++ b/Reqnroll/Formatters/Configuration/IReqnrollConfigConfigurationResolver.cs @@ -0,0 +1,3 @@ +namespace Reqnroll.Formatters.Configuration; + +public interface IReqnrollConfigConfigurationResolver : IFormattersConfigurationResolverBase; diff --git a/Reqnroll/Formatters/Configuration/ReqnrollConfigConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/ReqnrollConfigConfigurationResolver.cs new file mode 100644 index 000000000..0e95c8000 --- /dev/null +++ b/Reqnroll/Formatters/Configuration/ReqnrollConfigConfigurationResolver.cs @@ -0,0 +1,38 @@ +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; + +/// +/// This class uses the Reqnroll Configuration and config loader to provide the formatters configuration. +/// +public class ReqnrollConfigConfigurationResolver : IReqnrollConfigConfigurationResolver +{ + private readonly IConfigurationLoader _configurationLoader; + private readonly IFormatterLog _log; + + public ReqnrollConfigConfigurationResolver( + IConfigurationLoader configurationLoader, + IFormatterLog log = null) + { + _configurationLoader = configurationLoader; + _log = log; + } + + /// + /// File-based configuration replaces entirely (does not merge with previous settings). + /// + public bool ShouldMergeSettings => false; + + public IDictionary Resolve() + { + var reqnrollConfig = _configurationLoader.Load(ConfigurationLoader.GetDefault()); + return reqnrollConfig.Formatters ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + } + +} \ No newline at end of file diff --git a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs index 3121282b3..b416722e5 100644 --- a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs +++ b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs @@ -118,7 +118,7 @@ public virtual void RegisterGlobalContainerDefaults(ObjectContainer container) container.RegisterTypeAs(); container.RegisterTypeAs(); container.RegisterTypeAs(); - container.RegisterTypeAs(); + container.RegisterTypeAs(); container.RegisterTypeAs(); container.RegisterTypeAs(); container.RegisterTypeAs(); diff --git a/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs b/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs index f5bab9220..e538c8e1a 100644 --- a/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs +++ b/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs @@ -13,6 +13,7 @@ using Reqnroll.Utils; using System.Reflection; using Reqnroll.Time; +using Reqnroll.Configuration.JsonConfig; namespace Reqnroll.Formatters.Tests; @@ -165,10 +166,8 @@ protected static string ActualResultLocationDirectory() var substitutionServiceMock = new Mock(); var env = new EnvironmentWrapper(); var envOptions = new EnvironmentOptions(env); - var jsonConfigFileLocator = new ReqnrollJsonLocator(); - var fileSystem = new FileSystem(); - var fileService = new FileService(); - var configFileResolver = new FileBasedConfigurationResolver(jsonConfigFileLocator, fileSystem, fileService); + var configLoader = new ConfigurationLoader(new ReqnrollJsonLocator()); + var configFileResolver = new ReqnrollConfigConfigurationResolver(configLoader); var jsonEnvConfigResolver = new JsonEnvironmentConfigurationResolver(envOptions); var keyValueEnvironmentConfigurationResolverMock = new Mock(); diff --git a/Tests/Reqnroll.RuntimeTests/Bindings/CultureInfoScopeTests.cs b/Tests/Reqnroll.RuntimeTests/Bindings/CultureInfoScopeTests.cs index f621e5285..d986b1cdf 100644 --- a/Tests/Reqnroll.RuntimeTests/Bindings/CultureInfoScopeTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Bindings/CultureInfoScopeTests.cs @@ -51,7 +51,7 @@ private static FeatureContext GetFeatureContext(CultureInfo cultureInfo) { return new FeatureContext(default, new FeatureInfo(cultureInfo, default, default, default), - new ReqnrollConfiguration(default, default, default, default, cultureInfo, default, default, default, default, default, default, default, default, default, default, default, default, default)); + new ReqnrollConfiguration(default, default, default, default, cultureInfo, default, default, default, default, default, default, default, default, default, default, default, default, default, default)); } } } diff --git a/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs b/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs index 7187bd704..bd8173ca8 100644 --- a/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs @@ -502,6 +502,8 @@ private void AssertDefaultJsonReqnrollConfiguration(ReqnrollConfiguration config config.AdditionalStepAssemblies.Should().NotBeNull(); config.AdditionalStepAssemblies.Should().BeEmpty(); + + config.Formatters.Should().BeEmpty(); } #region Formatters Deserialization Tests @@ -528,25 +530,10 @@ public void Check_Formatters_Html_OutputFilePath() 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"); + 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() @@ -560,8 +547,8 @@ public void Check_Formatters_Multiple_Known_Formatters() 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"); + jsonConfig.Formatters["html"].OutputFilePath.Should().Be("report.html"); + jsonConfig.Formatters["message"].OutputFilePath.Should().Be("messages.ndjson"); } [Fact] @@ -576,8 +563,7 @@ public void Check_Formatters_CustomFormatter_CapturedInAdditionalFormatters() 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"); + jsonConfig.Formatters["customFormatter"].Should().NotBeNull(); } [Fact] @@ -594,8 +580,8 @@ public void Check_Formatters_Html_AdditionalOptions_Captured() 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"); + jsonConfig.Formatters["html"].OutputFilePath.Should().Be("report.html"); + jsonConfig.Formatters["html"].AdditionalOptions.Should().ContainKey("customOption"); } [Fact] @@ -609,8 +595,8 @@ public void Check_Formatters_EmptyFormatter_Parsed() var jsonConfig = System.Text.Json.JsonSerializer.Deserialize(config, JsonConfigurationSourceGenerator.Default.JsonConfig); - jsonConfig.Formatters.Html.Should().NotBeNull(); - jsonConfig.Formatters.Html.OutputFilePath.Should().BeNull(); + jsonConfig.Formatters["html"].Should().NotBeNull(); + jsonConfig.Formatters["html"].OutputFilePath.Should().BeNull(); } #endregion diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs index bb9f2f9c9..f3f1f52d6 100644 --- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs @@ -9,7 +9,7 @@ namespace Reqnroll.RuntimeTests.Formatters.Configuration; public class CucumberConfigurationTests { private readonly Mock _disableOverrideProviderMock; - private readonly Mock _fileResolverMock; + private readonly Mock _fileResolverMock; private readonly Mock _jsonEnvironmentResolverMock; private readonly Mock _keyValueEnvironmentResolverMock; private readonly Mock _variableSubstitutionServiceMock; @@ -18,7 +18,7 @@ public class CucumberConfigurationTests public CucumberConfigurationTests() { _disableOverrideProviderMock = new Mock(); - _fileResolverMock = new Mock(); + _fileResolverMock = new Mock(); _jsonEnvironmentResolverMock = new Mock(); _keyValueEnvironmentResolverMock = new Mock(); _variableSubstitutionServiceMock = new Mock(); diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FileBasedConfigurationResolverTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FileBasedConfigurationResolverTests.cs index 8b5478562..f7d8ae464 100644 --- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FileBasedConfigurationResolverTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FileBasedConfigurationResolverTests.cs @@ -1,47 +1,37 @@ using FluentAssertions; using Moq; -using Reqnroll.Analytics.UserId; using Reqnroll.Configuration; +using Reqnroll.Configuration.JsonConfig; using Reqnroll.Formatters.Configuration; using Reqnroll.Formatters.RuntimeSupport; -using Reqnroll.Utils; -using System; -using System.Collections.Generic; using Xunit; namespace Reqnroll.RuntimeTests.Formatters.Configuration; public class FileBasedConfigurationResolverTests { - private readonly Mock _jsonLocatorMock; - private readonly Mock _fileSystemMock; - private readonly Mock _fileServiceMock; + private readonly Mock _configurationLoaderMock; // ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable private readonly Mock _log; - private readonly FileBasedConfigurationResolver _sut; + private readonly ReqnrollConfigConfigurationResolver _sut; public FileBasedConfigurationResolverTests() { - _jsonLocatorMock = new Mock(); - _fileSystemMock = new Mock(); - _fileServiceMock = new Mock(); + _configurationLoaderMock = new Mock(); _log = new Mock(); - _sut = new FileBasedConfigurationResolver( - _jsonLocatorMock.Object, - _fileSystemMock.Object, - _fileServiceMock.Object, + + _sut = new ReqnrollConfigConfigurationResolver( + _configurationLoaderMock.Object, _log.Object ); } [Fact] - public void Resolve_Should_Return_Empty_Dictionary_When_File_Does_Not_Exist() + public void Resolve_Should_Return_Empty_Dictionary_When_Config_File_Has_No_Formatters() { // Arrange - _jsonLocatorMock.Setup(locator => locator.GetReqnrollJsonFilePath()).Returns("nonexistent.json"); - _fileSystemMock.Setup(fs => fs.FileExists("nonexistent.json")).Returns(false); - + _configurationLoaderMock.Setup(x => x.Load(It.IsAny())).Returns(ConfigurationLoader.GetDefault()); // Act var result = _sut.Resolve(); @@ -50,87 +40,82 @@ public void Resolve_Should_Return_Empty_Dictionary_When_File_Does_Not_Exist() } [Fact] - public void Resolve_Should_Return_Empty_Dictionary_When_File_Has_No_Formatters() + public void Resolve_Should_Return_Formatters_From_Valid_File() { // Arrange - var filePath = "config.json"; - var fileContent = "{}"; + var reqnrollConfig = ConfigurationLoader.GetDefault(); + var jsonLoader = new JsonConfigurationLoader(); + reqnrollConfig = jsonLoader.LoadJson(reqnrollConfig, @"{ + ""formatters"": { + ""formatter1"": { + ""config1"": ""setting1"" + }, + ""formatter2"": { + ""config2"": ""setting2"" + } + } + }"); - _jsonLocatorMock.Setup(locator => locator.GetReqnrollJsonFilePath()).Returns(filePath); - _fileSystemMock.Setup(fs => fs.FileExists(filePath)).Returns(true); - _fileServiceMock.Setup(fs => fs.ReadAllText(filePath)).Returns(fileContent); + _configurationLoaderMock.Setup(x => x.Load(It.IsAny())).Returns(reqnrollConfig); // Act var result = _sut.Resolve(); // Assert - result.Should().BeEmpty(); + result.Should().HaveCount(2); + result["formatter1"].AdditionalSettings["config1"].Should().Be("setting1"); + result["formatter2"].AdditionalSettings["config2"].Should().Be("setting2"); } [Fact] - public void Resolve_Should_Return_Formatters_From_Valid_File() + public void Resolve_Should_Return_An_EmptyEntry_When_Key_Has_no_Content() { // Arrange - var filePath = "config.json"; - var fileContent = @" - { - ""formatters"": { - ""formatter1"": { - ""config1"": ""setting1"" }, - ""formatter2"": { - ""config2"": ""setting2"" } + var reqnrollConfig = ConfigurationLoader.GetDefault(); + var jsonLoader = new JsonConfigurationLoader(); + reqnrollConfig = jsonLoader.LoadJson(reqnrollConfig, @"{ + ""formatters"": { + ""emptyFormatter"": {} } - }"; + }"); - _jsonLocatorMock.Setup(locator => locator.GetReqnrollJsonFilePath()).Returns(filePath); - _fileSystemMock.Setup(fs => fs.FileExists(filePath)).Returns(true); - _fileServiceMock.Setup(fs => fs.ReadAllText(filePath)).Returns(fileContent); + _configurationLoaderMock.Setup(x => x.Load(It.IsAny())).Returns(reqnrollConfig); // Act var result = _sut.Resolve(); // Assert - result.Should().HaveCount(2); - result["formatter1"].AdditionalSettings["config1"].Should().Be("setting1"); - result["formatter2"].AdditionalSettings["config2"].Should().Be("setting2"); + result.Should().HaveCount(1); + result["emptyFormatter"].OutputFilePath.Should().BeNull(); + result["emptyFormatter"].AdditionalSettings.Should().BeEmpty(); } [Fact] - public void Resolve_Should_Handle_Invalid_Json_File_ByEmittingLog_and_ReturningEmpty() + public void Resolve_Should_Map_OutputFilePath() { // Arrange - var filePath = "config.json"; - var invalidJsonContent = "{ blah blah json }"; + var reqnrollConfig = ConfigurationLoader.GetDefault(); + reqnrollConfig = new JsonConfigurationLoader().LoadJson(reqnrollConfig, @"{ + ""formatters"": { + ""html"": { ""outputFilePath"": ""output/report.html"" } + } + }"); + _configurationLoaderMock.Setup(x => x.Load(It.IsAny())).Returns(reqnrollConfig); - _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 = null; // Act - var act = () => result = _sut.Resolve(); + var result = _sut.Resolve(); // Assert - act.Should().NotThrow(); - result = _sut.Resolve(); - result.Should().BeEmpty(); - + result["html"].OutputFilePath.Should().Be("output/report.html"); } [Fact] - public void Resolve_Should_Handle_File_With_No_Formatters_Key() + public void Resolve_Should_Return_Empty_When_Loader_Returns_Null_Formatters() { // Arrange - var filePath = "config.json"; - var fileContent = @" - { - ""otherKey"": { - ""key1"": ""value1"" - } - }"; - - _jsonLocatorMock.Setup(locator => locator.GetReqnrollJsonFilePath()).Returns(filePath); - _fileSystemMock.Setup(fs => fs.FileExists(filePath)).Returns(true); - _fileServiceMock.Setup(fs => fs.ReadAllText(filePath)).Returns(fileContent); + var config = ConfigurationLoader.GetDefault(); + config.Formatters = null; + _configurationLoaderMock.Setup(x => x.Load(It.IsAny())).Returns(config); // Act var result = _sut.Resolve(); @@ -140,24 +125,8 @@ public void Resolve_Should_Handle_File_With_No_Formatters_Key() } [Fact] - public void Resolve_Should_Return_An_EmptyEntry_When_Key_Has_no_Content() + public void ShouldMergeSettings_Should_Be_False() { - // Arrange - var filePath = "config.json"; - var fileContent = @" - { - ""formatters"": { - ""emptyFormatter"": {} - } - }"; - _jsonLocatorMock.Setup(locator => locator.GetReqnrollJsonFilePath()).Returns(filePath); - _fileSystemMock.Setup(fs => fs.FileExists(filePath)).Returns(true); - _fileServiceMock.Setup(fs => fs.ReadAllText(filePath)).Returns(fileContent); - // Act - var result = _sut.Resolve(); - // Assert - result.Should().HaveCount(1); - result["emptyFormatter"].OutputFilePath.Should().BeNull(); - result["emptyFormatter"].AdditionalSettings.Should().BeEmpty(); + _sut.ShouldMergeSettings.Should().BeFalse(); } } \ No newline at end of file diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs index 114fd0dc6..37d0bee29 100644 --- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs @@ -9,241 +9,106 @@ 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() + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ExtractFormatters_Should_Return_Empty_For_NullOrWhitespace(string input) { - // Act - var result = FormattersConfigExtractor.ExtractFormatters(""); - - // Assert - result.Should().BeEmpty(); + FormattersConfigExtractor.ExtractFormatters(input).Should().BeEmpty(); } [Fact] - public void ExtractFormatters_Should_Return_Empty_Dictionary_When_Json_Has_No_Formatters() + public void ExtractFormatters_Should_Return_Empty_For_Invalid_Json() { - // 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"); + FormattersConfigExtractor.ExtractFormatters("{ not json }").Should().BeEmpty(); } [Fact] - public void ExtractFormatters_Should_Extract_Custom_Formatter_Via_AdditionalFormatters() + public void ExtractFormatters_Should_Return_Empty_When_No_Formatters_Key() { - // Arrange - var json = @"{ - ""formatters"": { - ""customFormatter"": { ""setting1"": ""value1"", ""setting2"": ""value2"" } - } - }"; - - // Act - var result = FormattersConfigExtractor.ExtractFormatters(json); - - // Assert - result.Should().ContainKey("customFormatter"); - result["customFormatter"].AdditionalSettings["setting1"].Should().Be("value1"); - result["customFormatter"].AdditionalSettings["setting2"].Should().Be("value2"); + FormattersConfigExtractor.ExtractFormatters(@"{ ""other"": {} }").Should().BeEmpty(); } [Fact] - public void ExtractFormatters_Should_Extract_Multiple_Formatters() + public void ExtractFormatters_Should_Return_Formatter_With_OutputFilePath() { - // Arrange - var json = @"{ - ""formatters"": { - ""html"": { ""outputFilePath"": ""report.html"" }, - ""message"": { ""outputFilePath"": ""messages.ndjson"" }, - ""custom"": { ""customSetting"": ""customValue"" } - } - }"; + var result = FormattersConfigExtractor.ExtractFormatters(@"{ + ""formatters"": { ""html"": { ""outputFilePath"": ""out.html"" } } + }"); - // Act - var result = FormattersConfigExtractor.ExtractFormatters(json); - - // Assert - result.Should().HaveCount(3); - result.Should().ContainKeys("html", "message", "custom"); + result["html"].OutputFilePath.Should().Be("out.html"); } [Fact] - public void ExtractFormatters_Should_Handle_Additional_Options_On_Known_Formatter() + public void ConvertFormatterOptions_Should_Return_Empty_Config_For_Null() { - // Arrange - var json = @"{ - ""formatters"": { - ""html"": { - ""outputFilePath"": ""report.html"", - ""customOption"": ""customValue"" - } - } - }"; + var result = FormattersConfigExtractor.ConvertFormatterOptions(null); - // Act - var result = FormattersConfigExtractor.ExtractFormatters(json); - - // Assert - result["html"].OutputFilePath.Should().Be("report.html"); - result["html"].AdditionalSettings["customOption"].Should().Be("customValue"); + result.Should().NotBeNull(); + result.OutputFilePath.Should().BeNull(); + result.AdditionalSettings.Should().BeEmpty(); } - [Fact] - public void ExtractFormatters_Should_Handle_Boolean_Config_Values() + [Theory] + [InlineData("stringVal", "hello", "hello")] + public void ConvertFormatterOptions_Should_Map_String_AdditionalOption(string key, string jsonValue, string expected) { - // Arrange - var json = @"{ - ""formatters"": { - ""custom"": { ""enabled"": true, ""debug"": false } + var options = new FormatterOptionsElement + { + AdditionalOptions = new Dictionary + { + { key, JsonDocument.Parse($@"""{jsonValue}""").RootElement } } - }"; + }; - // Act - var result = FormattersConfigExtractor.ExtractFormatters(json); + var result = FormattersConfigExtractor.ConvertFormatterOptions(options); - // Assert - result["custom"].AdditionalSettings["enabled"].Should().Be(true); - result["custom"].AdditionalSettings["debug"].Should().Be(false); + result.AdditionalSettings[key].Should().Be(expected); } [Fact] - public void ExtractFormatters_Should_Handle_Numeric_Config_Values() + public void ConvertFormatterOptions_Should_Map_Boolean_AdditionalOption() { - // Arrange - var json = @"{ - ""formatters"": { - ""custom"": { ""timeout"": 30.5, ""retries"": 3 } + var options = new FormatterOptionsElement + { + AdditionalOptions = new Dictionary + { + { "flag", JsonDocument.Parse("true").RootElement } } - }"; - - // Act - var result = FormattersConfigExtractor.ExtractFormatters(json); - - // Assert - result["custom"].AdditionalSettings["timeout"].Should().Be(30.5); - result["custom"].AdditionalSettings["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(); + FormattersConfigExtractor.ConvertFormatterOptions(options) + .AdditionalSettings["flag"].Should().Be(true); } [Fact] - public void ConvertFormattersElement_Should_Handle_Empty_Formatter_Options() + public void ConvertFormatterOptions_Should_Map_Integer_AdditionalOption() { - // Arrange - var element = new FormattersElement + var options = new FormatterOptionsElement { - Html = new FormatterOptionsElement() + AdditionalOptions = new Dictionary + { + { "count", JsonDocument.Parse("42").RootElement } + } }; - // Act - var result = FormattersConfigExtractor.ConvertFormattersElement(element); - - // Assert - result.Should().ContainKey("html"); - result["html"].OutputFilePath.Should().BeNull(); - result["html"].AdditionalSettings.Should().BeEmpty(); + FormattersConfigExtractor.ConvertFormatterOptions(options) + .AdditionalSettings["count"].Should().Be(42L); } [Fact] - public void ExtractFormatters_Should_Be_Case_Insensitive_For_Formatter_Names() + public void ConvertFormatterOptions_Should_Exclude_Null_AdditionalOption() { - // Arrange - var json = @"{ - ""formatters"": { - ""HTML"": { ""outputFilePath"": ""report.html"" } + var options = new FormatterOptionsElement + { + AdditionalOptions = new Dictionary + { + { "nullKey", JsonDocument.Parse("null").RootElement } } - }"; - - // 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"); + FormattersConfigExtractor.ConvertFormatterOptions(options) + .AdditionalSettings.Should().NotContainKey("nullKey"); } } diff --git a/Tests/Reqnroll.RuntimeTests/Infrastructure/ReqnrollOutputHelperTests.cs b/Tests/Reqnroll.RuntimeTests/Infrastructure/ReqnrollOutputHelperTests.cs index b69089e19..b4099ee02 100644 --- a/Tests/Reqnroll.RuntimeTests/Infrastructure/ReqnrollOutputHelperTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Infrastructure/ReqnrollOutputHelperTests.cs @@ -41,7 +41,7 @@ private ReqnrollOutputHelper CreateReqnrollOutputHelper() var attachmentHandlerMock = new Mock(); var contextManager = new Mock(); var featureInfo = new FeatureInfo(new System.Globalization.CultureInfo("en-US"), "", "test feature", null); - var config = new ReqnrollConfiguration(ConfigSource.Json, null, null, null, null, false, MissingOrPendingStepsOutcome.Error, false, false, TimeSpan.FromSeconds(10), Reqnroll.BindingSkeletons.StepDefinitionSkeletonStyle.CucumberExpressionAttribute, null, false, false, new string[] { }, true, ObsoleteBehavior.Error, false); + var config = new ReqnrollConfiguration(ConfigSource.Json, null, null, null, null, false, MissingOrPendingStepsOutcome.Error, false, false, TimeSpan.FromSeconds(10), Reqnroll.BindingSkeletons.StepDefinitionSkeletonStyle.CucumberExpressionAttribute, null, false, false, new string[] { }, true, ObsoleteBehavior.Error, false, null); var featureContext = new FeatureContext(null, featureInfo, config); contextManager.SetupGet(c => c.FeatureContext).Returns(featureContext);