diff --git a/CHANGELOG.md b/CHANGELOG.md index c09d08b63..2b31fb1ad 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.4 - 2026-03-23 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/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/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 new file mode 100644 index 000000000..38b3ddd94 --- /dev/null +++ b/Reqnroll/Configuration/JsonConfig/FormattersElement.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Reqnroll.Configuration.JsonConfig; + +/// +/// 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 7b29d1a13..2faddbb6c 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 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/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 f008c707a..000000000 --- a/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Reqnroll.Analytics.UserId; -using Reqnroll.Configuration; -using Reqnroll.Formatters.RuntimeSupport; -using Reqnroll.Utils; -using System; -using System.Text.Json; - -namespace Reqnroll.Formatters.Configuration; - -public class FileBasedConfigurationResolver : FormattersConfigurationResolverBase, 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; - } - - protected override JsonDocument GetJsonDocument() - { - 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; - } - - 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; - } - } - 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/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 new file mode 100644 index 000000000..56982448d --- /dev/null +++ b/Reqnroll/Formatters/Configuration/FormattersConfigExtractor.cs @@ -0,0 +1,107 @@ +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. +/// +public static class FormattersConfigExtractor +{ + /// + /// 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) + { + if (string.IsNullOrWhiteSpace(jsonContent)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var jsonConfig = JsonSerializer.Deserialize(jsonContent, FormattersConfigurationSourceGenerator.Default.FormattersElement); + return ConvertFormattersElement(jsonConfig); + } + catch (JsonException) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + + /// + /// Converts a FormattersElement to typed FormatterConfiguration objects. + /// + /// 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; + + if (formatters.Formatters != null) + foreach (var kvp in formatters.Formatters) + { + result[kvp.Key] = ConvertFormatterOptions(kvp.Value); + } + + return result; + } + + internal static FormatterConfiguration ConvertFormatterOptions(FormatterOptionsElement options) + { + if (options == null) + return new FormatterConfiguration(); + + var config = new FormatterConfiguration + { + OutputFilePath = options.OutputFilePath + }; + + // Process additional options captured by JsonExtensionData + if (options.AdditionalOptions != null) + foreach (var kvp in options.AdditionalOptions) + { + var value = ConvertJsonElement(kvp.Value); + if (value != null) + { + config.AdditionalSettings[kvp.Key] = value; + } + } + + return config; + } + + private static object ConvertJsonElement(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.Number: + 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: + 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/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..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); @@ -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)) @@ -41,16 +42,27 @@ public IDictionary GetFormatterConfigurationByName(string format 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/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/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/IFormattersConfigurationProvider.cs b/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs index 446e92b2d..f63c663f3 100644 --- a/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs +++ b/Reqnroll/Formatters/Configuration/IFormattersConfigurationProvider.cs @@ -1,11 +1,19 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.ComponentModel; namespace Reqnroll.Formatters.Configuration; public interface IFormattersConfigurationProvider { bool Enabled { get; } - IDictionary GetFormatterConfigurationByName(string formatterName); + + /// + /// 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); string ResolveTemplatePlaceholders(string template); } \ No newline at end of file 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/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/JsonEnvironmentConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs index 86678f0df..b6a1083c0 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,12 @@ public JsonEnvironmentConfigurationResolver( _log = log; } - protected override JsonDocument GetJsonDocument() + /// + /// JSON-based configuration replaces entirely (does not merge with previous settings). + /// + public bool ShouldMergeSettings => false; + + public IDictionary Resolve() { try { @@ -29,11 +35,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 +53,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/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/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/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/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 856177839..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,14 +166,13 @@ 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(); - 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 +181,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/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 6352d6386..bd8173ca8 100644 --- a/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Configuration/JsonConfigTests.cs @@ -502,6 +502,103 @@ private void AssertDefaultJsonReqnrollConfiguration(ReqnrollConfiguration config config.AdditionalStepAssemblies.Should().NotBeNull(); config.AdditionalStepAssemblies.Should().BeEmpty(); + + config.Formatters.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_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["customFormatter"].Should().NotBeNull(); + } + + [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/CucumberConfigurationTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs index fd1a8fa56..f3f1f52d6 100644 --- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs @@ -9,24 +9,34 @@ namespace Reqnroll.RuntimeTests.Formatters.Configuration; public class CucumberConfigurationTests { private readonly Mock _disableOverrideProviderMock; - private readonly Mock _fileResolverMock; - private readonly Mock _environmentResolverMock; + private readonly Mock _fileResolverMock; + private readonly Mock _jsonEnvironmentResolverMock; + private readonly Mock _keyValueEnvironmentResolverMock; private readonly Mock _variableSubstitutionServiceMock; private readonly FormattersConfigurationProvider _sut; public CucumberConfigurationTests() { _disableOverrideProviderMock = new Mock(); - _fileResolverMock = new Mock(); - _environmentResolverMock = new Mock(); + _fileResolverMock = 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..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"]["config1"].Should().Be("setting1"); - result["formatter2"]["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; // 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,23 +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"].Should().BeEmpty(); + _sut.ShouldMergeSettings.Should().BeFalse(); } } \ 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 new file mode 100644 index 000000000..37d0bee29 --- /dev/null +++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/FormattersConfigExtractorTests.cs @@ -0,0 +1,114 @@ +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 +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ExtractFormatters_Should_Return_Empty_For_NullOrWhitespace(string input) + { + FormattersConfigExtractor.ExtractFormatters(input).Should().BeEmpty(); + } + + [Fact] + public void ExtractFormatters_Should_Return_Empty_For_Invalid_Json() + { + FormattersConfigExtractor.ExtractFormatters("{ not json }").Should().BeEmpty(); + } + + [Fact] + public void ExtractFormatters_Should_Return_Empty_When_No_Formatters_Key() + { + FormattersConfigExtractor.ExtractFormatters(@"{ ""other"": {} }").Should().BeEmpty(); + } + + [Fact] + public void ExtractFormatters_Should_Return_Formatter_With_OutputFilePath() + { + var result = FormattersConfigExtractor.ExtractFormatters(@"{ + ""formatters"": { ""html"": { ""outputFilePath"": ""out.html"" } } + }"); + + result["html"].OutputFilePath.Should().Be("out.html"); + } + + [Fact] + public void ConvertFormatterOptions_Should_Return_Empty_Config_For_Null() + { + var result = FormattersConfigExtractor.ConvertFormatterOptions(null); + + result.Should().NotBeNull(); + result.OutputFilePath.Should().BeNull(); + result.AdditionalSettings.Should().BeEmpty(); + } + + [Theory] + [InlineData("stringVal", "hello", "hello")] + public void ConvertFormatterOptions_Should_Map_String_AdditionalOption(string key, string jsonValue, string expected) + { + var options = new FormatterOptionsElement + { + AdditionalOptions = new Dictionary + { + { key, JsonDocument.Parse($@"""{jsonValue}""").RootElement } + } + }; + + var result = FormattersConfigExtractor.ConvertFormatterOptions(options); + + result.AdditionalSettings[key].Should().Be(expected); + } + + [Fact] + public void ConvertFormatterOptions_Should_Map_Boolean_AdditionalOption() + { + var options = new FormatterOptionsElement + { + AdditionalOptions = new Dictionary + { + { "flag", JsonDocument.Parse("true").RootElement } + } + }; + + FormattersConfigExtractor.ConvertFormatterOptions(options) + .AdditionalSettings["flag"].Should().Be(true); + } + + [Fact] + public void ConvertFormatterOptions_Should_Map_Integer_AdditionalOption() + { + var options = new FormatterOptionsElement + { + AdditionalOptions = new Dictionary + { + { "count", JsonDocument.Parse("42").RootElement } + } + }; + + FormattersConfigExtractor.ConvertFormatterOptions(options) + .AdditionalSettings["count"].Should().Be(42L); + } + + [Fact] + public void ConvertFormatterOptions_Should_Exclude_Null_AdditionalOption() + { + var options = new FormatterOptionsElement + { + AdditionalOptions = new Dictionary + { + { "nullKey", JsonDocument.Parse("null").RootElement } + } + }; + + FormattersConfigExtractor.ConvertFormatterOptions(options) + .AdditionalSettings.Should().NotContainKey("nullKey"); + } +} 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); 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);