diff --git a/Reqnroll/EnvironmentAccess/EnvironmentWrapper.cs b/Reqnroll/EnvironmentAccess/EnvironmentWrapper.cs index 4ac9ebb9f..fa742bd20 100644 --- a/Reqnroll/EnvironmentAccess/EnvironmentWrapper.cs +++ b/Reqnroll/EnvironmentAccess/EnvironmentWrapper.cs @@ -1,5 +1,8 @@ -using System; using Reqnroll.CommonModels; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; namespace Reqnroll.EnvironmentAccess { @@ -36,5 +39,17 @@ public void SetEnvironmentVariable(string name, string value) } public string GetCurrentDirectory() => Environment.CurrentDirectory; + + public IDictionary GetEnvironmentVariables(string prefix) + { + if (string.IsNullOrEmpty(prefix)) + throw new ArgumentException("Argument cannot be null or empty", nameof(prefix)); + + return Environment.GetEnvironmentVariables() + .OfType() + .Select(e => (Key: e.Key?.ToString() ?? "", Value: e.Value?.ToString() ?? "")) + .Where(e => e.Key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ToDictionary(e => e.Key, e => e.Value); + } } } diff --git a/Reqnroll/EnvironmentAccess/IEnvironmentWrapper.cs b/Reqnroll/EnvironmentAccess/IEnvironmentWrapper.cs index 5e88ba8bc..590c69e0a 100644 --- a/Reqnroll/EnvironmentAccess/IEnvironmentWrapper.cs +++ b/Reqnroll/EnvironmentAccess/IEnvironmentWrapper.cs @@ -1,4 +1,5 @@ using Reqnroll.CommonModels; +using System.Collections.Generic; namespace Reqnroll.EnvironmentAccess { @@ -10,6 +11,8 @@ public interface IEnvironmentWrapper IResult GetEnvironmentVariable(string name); + IDictionary GetEnvironmentVariables(string prefix); + void SetEnvironmentVariable(string name, string value); string GetCurrentDirectory(); diff --git a/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs index 0bb8dff7d..f008c707a 100644 --- a/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs +++ b/Reqnroll/Formatters/Configuration/FileBasedConfigurationResolver.cs @@ -7,7 +7,7 @@ namespace Reqnroll.Formatters.Configuration; -public class FileBasedConfigurationResolver : FormattersConfigurationResolverBase, IFormattersConfigurationResolver +public class FileBasedConfigurationResolver : FormattersConfigurationResolverBase, IFileBasedConfigurationResolver { private readonly IReqnrollJsonLocator _configFileLocator; private readonly IFileSystem _fileSystem; diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigurationConstants.cs b/Reqnroll/Formatters/Configuration/FormattersConfigurationConstants.cs index 1645d5202..e4df77131 100644 --- a/Reqnroll/Formatters/Configuration/FormattersConfigurationConstants.cs +++ b/Reqnroll/Formatters/Configuration/FormattersConfigurationConstants.cs @@ -3,5 +3,6 @@ public static class FormattersConfigurationConstants { public const string REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE = "REQNROLL_FORMATTERS"; + public const string REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX = "REQNROLL_FORMATTERS_"; public const string REQNROLL_FORMATTERS_DISABLED_ENVIRONMENT_VARIABLE = "REQNROLL_FORMATTERS_DISABLED"; } \ No newline at end of file diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs b/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs index e9cffe53f..438983856 100644 --- a/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs +++ b/Reqnroll/Formatters/Configuration/FormattersConfigurationProvider.cs @@ -11,7 +11,7 @@ namespace Reqnroll.Formatters.Configuration; /// the class will resolve the configuration (only once). /// /// One or more profiles may be read from the configuration file () -/// then environment variable overrides are applied (). +/// then environment variable overrides are applied (first , then ). /// public class FormattersConfigurationProvider : IFormattersConfigurationProvider { @@ -20,10 +20,9 @@ public class FormattersConfigurationProvider : IFormattersConfigurationProvider private readonly IFormattersConfigurationDisableOverrideProvider _envVariableDisableFlagProvider; public bool Enabled => _resolvedConfiguration.Value.Enabled; - public FormattersConfigurationProvider(IDictionary resolvers, IFormattersEnvironmentOverrideConfigurationResolver environmentOverrideConfigurationResolver, IFormattersConfigurationDisableOverrideProvider envVariableDisableFlagProvider) + public FormattersConfigurationProvider(IFileBasedConfigurationResolver fileBasedConfigurationResolver, IJsonEnvironmentConfigurationResolver jsonEnvironmentConfigurationResolver, IKeyValueEnvironmentConfigurationResolver keyValueEnvironmentConfigurationResolver, IFormattersConfigurationDisableOverrideProvider envVariableDisableFlagProvider) { - var fileResolver = resolvers["fileBasedResolver"]; - _resolvers = [fileResolver, environmentOverrideConfigurationResolver]; + _resolvers = [fileBasedConfigurationResolver, jsonEnvironmentConfigurationResolver, keyValueEnvironmentConfigurationResolver]; _resolvedConfiguration = new Lazy(ResolveConfiguration); _envVariableDisableFlagProvider = envVariableDisableFlagProvider; } @@ -38,13 +37,16 @@ public IDictionary GetFormatterConfigurationByName(string format private FormattersConfiguration ResolveConfiguration() { - var combinedConfig = new Dictionary>(); + var combinedConfig = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var resolver in _resolvers) { foreach (var entry in resolver.Resolve()) { - combinedConfig[entry.Key] = entry.Value; + if (entry.Value == null) + combinedConfig.Remove(entry.Key); + else + combinedConfig[entry.Key] = entry.Value; } } bool enabled = combinedConfig.Count > 0 && !_envVariableDisableFlagProvider.Disabled(); @@ -55,4 +57,4 @@ private FormattersConfiguration ResolveConfiguration() Enabled = enabled }; } -} \ No newline at end of file +} diff --git a/Reqnroll/Formatters/Configuration/FormattersConfigurationResolverBase.cs b/Reqnroll/Formatters/Configuration/FormattersConfigurationResolverBase.cs index b6b6a7a36..aca957736 100644 --- a/Reqnroll/Formatters/Configuration/FormattersConfigurationResolverBase.cs +++ b/Reqnroll/Formatters/Configuration/FormattersConfigurationResolverBase.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text.Json; @@ -9,7 +10,7 @@ public abstract class FormattersConfigurationResolverBase : IFormattersConfigura public IDictionary> Resolve() { - var result = new Dictionary>(); + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); JsonDocument jsonDocument = GetJsonDocument(); if (jsonDocument != null) @@ -28,20 +29,21 @@ protected virtual void ProcessJsonDocument(JsonDocument jsonDocument, Dictionary { foreach(JsonProperty formatterProperty in formatters.EnumerateObject()) { - var configValues = new Dictionary(); - + 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)); + configValues.Add(configProperty.Name, GetConfigValue(configProperty.Value)); } } - + result.Add(formatterProperty.Name, configValues); } } } + private object GetConfigValue(JsonElement valueElement) { switch (valueElement.ValueKind) diff --git a/Reqnroll/Formatters/Configuration/IFileBasedConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/IFileBasedConfigurationResolver.cs new file mode 100644 index 000000000..189abd1f3 --- /dev/null +++ b/Reqnroll/Formatters/Configuration/IFileBasedConfigurationResolver.cs @@ -0,0 +1,3 @@ +namespace Reqnroll.Formatters.Configuration; + +public interface IFileBasedConfigurationResolver : IFormattersConfigurationResolverBase; diff --git a/Reqnroll/Formatters/Configuration/IFormattersConfigurationResolverBase.cs b/Reqnroll/Formatters/Configuration/IFormattersConfigurationResolverBase.cs index 85f58da0c..a6fc788bb 100644 --- a/Reqnroll/Formatters/Configuration/IFormattersConfigurationResolverBase.cs +++ b/Reqnroll/Formatters/Configuration/IFormattersConfigurationResolverBase.cs @@ -5,8 +5,4 @@ namespace Reqnroll.Formatters.Configuration; public interface IFormattersConfigurationResolverBase { IDictionary> Resolve(); -} - -public interface IFormattersConfigurationResolver : IFormattersConfigurationResolverBase; - -public interface IFormattersEnvironmentOverrideConfigurationResolver : IFormattersConfigurationResolverBase; \ No newline at end of file +} \ No newline at end of file diff --git a/Reqnroll/Formatters/Configuration/IJsonEnvironmentConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/IJsonEnvironmentConfigurationResolver.cs new file mode 100644 index 000000000..f36203308 --- /dev/null +++ b/Reqnroll/Formatters/Configuration/IJsonEnvironmentConfigurationResolver.cs @@ -0,0 +1,3 @@ +namespace Reqnroll.Formatters.Configuration; + +public interface IJsonEnvironmentConfigurationResolver : IFormattersConfigurationResolverBase; diff --git a/Reqnroll/Formatters/Configuration/IKeyValueEnvironmentConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/IKeyValueEnvironmentConfigurationResolver.cs new file mode 100644 index 000000000..28c44fb52 --- /dev/null +++ b/Reqnroll/Formatters/Configuration/IKeyValueEnvironmentConfigurationResolver.cs @@ -0,0 +1,3 @@ +namespace Reqnroll.Formatters.Configuration; + +public interface IKeyValueEnvironmentConfigurationResolver : IFormattersConfigurationResolverBase; diff --git a/Reqnroll/Formatters/Configuration/EnvironmentConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs similarity index 62% rename from Reqnroll/Formatters/Configuration/EnvironmentConfigurationResolver.cs rename to Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs index 9bf011eb5..ac0f37ca8 100644 --- a/Reqnroll/Formatters/Configuration/EnvironmentConfigurationResolver.cs +++ b/Reqnroll/Formatters/Configuration/JsonEnvironmentConfigurationResolver.cs @@ -6,30 +6,42 @@ namespace Reqnroll.Formatters.Configuration; -public class EnvironmentConfigurationResolver : FormattersConfigurationResolverBase, IFormattersEnvironmentOverrideConfigurationResolver +public class JsonEnvironmentConfigurationResolver : FormattersConfigurationResolverBase, IJsonEnvironmentConfigurationResolver { private readonly IEnvironmentWrapper _environmentWrapper; private readonly IFormatterLog _log; + private readonly string _environmentVariableName; - public EnvironmentConfigurationResolver( + public JsonEnvironmentConfigurationResolver( IEnvironmentWrapper environmentWrapper, IFormatterLog log = null) { _environmentWrapper = environmentWrapper; _log = log; + _environmentVariableName = FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE; + } + + internal JsonEnvironmentConfigurationResolver( + IEnvironmentWrapper environmentWrapper, + string environmentVariableName, + IFormatterLog log = null) + { + _environmentWrapper = environmentWrapper ?? throw new ArgumentNullException(nameof(environmentWrapper)); + _log = log; + _environmentVariableName = environmentVariableName ?? throw new ArgumentNullException(nameof(environmentVariableName)); } protected override JsonDocument GetJsonDocument() { try { - var formatters = _environmentWrapper.GetEnvironmentVariable(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE); + var formatters = _environmentWrapper.GetEnvironmentVariable(_environmentVariableName); if (formatters is Success formattersSuccess) { if (string.IsNullOrWhiteSpace(formattersSuccess.Result)) { - _log?.WriteMessage($"Environment variable {FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE} is empty"); + _log?.WriteMessage($"Environment variable {_environmentVariableName} is empty"); return null; } @@ -43,12 +55,12 @@ protected override JsonDocument GetJsonDocument() } catch (JsonException ex) { - _log?.WriteMessage($"Failed to parse JSON from environment variable {FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE}: {ex.Message}"); + _log?.WriteMessage($"Failed to parse JSON from environment variable {_environmentVariableName}: {ex.Message}"); } } else if (formatters is Failure failure) { - _log?.WriteMessage($"Could not retrieve environment variable {FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE}: {failure.Description}"); + _log?.WriteMessage($"Could not retrieve environment variable {_environmentVariableName}: {failure.Description}"); } } catch (Exception ex) when (ex is not JsonException) diff --git a/Reqnroll/Formatters/Configuration/KeyValueEnvironmentConfigurationResolver.cs b/Reqnroll/Formatters/Configuration/KeyValueEnvironmentConfigurationResolver.cs new file mode 100644 index 000000000..bb2f16d4d --- /dev/null +++ b/Reqnroll/Formatters/Configuration/KeyValueEnvironmentConfigurationResolver.cs @@ -0,0 +1,55 @@ +using System; +using Reqnroll.EnvironmentAccess; +using Reqnroll.Formatters.RuntimeSupport; +using System.Collections.Generic; + +namespace Reqnroll.Formatters.Configuration; + +internal class KeyValueEnvironmentConfigurationResolver(IEnvironmentWrapper environmentWrapper, IFormatterLog log = null) : IKeyValueEnvironmentConfigurationResolver +{ + private readonly IEnvironmentWrapper _environmentWrapper = environmentWrapper ?? throw new ArgumentNullException(nameof(environmentWrapper)); + + public IDictionary> Resolve() + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var environmentVariables = _environmentWrapper.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX); + foreach (var formatterEnvironmentVariable in environmentVariables) + { + var formatterName = formatterEnvironmentVariable.Key.Substring(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX.Length); + var formatterConfiguration = formatterEnvironmentVariable.Value?.Trim(); + if (string.IsNullOrEmpty(formatterConfiguration)) + continue; + + log?.WriteMessage($"Configuring formatter '{formatterName}' via environment variable {formatterEnvironmentVariable.Key}={formatterEnvironmentVariable.Value}"); + + if (formatterConfiguration.Equals("true", StringComparison.InvariantCultureIgnoreCase)) + { + result[formatterName] = new Dictionary(StringComparer.OrdinalIgnoreCase); + continue; + } + + if (formatterConfiguration.Equals("false", StringComparison.InvariantCultureIgnoreCase)) + { + result[formatterName] = null; + continue; + } + + var settings = formatterConfiguration.Split(';'); + + var configValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + 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(); + } + + result[formatterName] = configValues; + } + + return result; + } +} \ No newline at end of file diff --git a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs index bd82b5667..dbf1816de 100644 --- a/Reqnroll/Infrastructure/DefaultDependencyProvider.cs +++ b/Reqnroll/Infrastructure/DefaultDependencyProvider.cs @@ -113,8 +113,9 @@ public virtual void RegisterGlobalContainerDefaults(ObjectContainer container) container.RegisterTypeAs(); container.RegisterTypeAs(); container.RegisterTypeAs(); - container.RegisterTypeAs("fileBasedResolver"); - container.RegisterTypeAs(); + container.RegisterTypeAs(); + container.RegisterTypeAs(); + container.RegisterTypeAs(); container.RegisterTypeAs(); container.RegisterTypeAs("message"); container.RegisterTypeAs("html"); diff --git a/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs b/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs index 232c0e5a3..870438329 100644 --- a/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs +++ b/Tests/Reqnroll.Formatters.Tests/MessagesCompatibilityTestBase.cs @@ -160,13 +160,16 @@ protected static string ActualResultLocationDirectory() var fileSystem = new FileSystem(); var fileService = new FileService(); var configFileResolver = new FileBasedConfigurationResolver(jsonConfigFileLocator, fileSystem, fileService); - var configEnvResolver = new EnvironmentConfigurationResolver(env); - var resolvers = new Dictionary - { - {"fileBasedResolver", configFileResolver } - }; + var jsonEnvConfigResolver = new JsonEnvironmentConfigurationResolver(env); + + var keyValueEnvironmentConfigurationResolverMock = new Mock(); + keyValueEnvironmentConfigurationResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>()); - FormattersConfigurationProvider configurationProvider = new FormattersConfigurationProvider(resolvers, configEnvResolver, new FormattersDisabledOverrideProvider(env)); + FormattersConfigurationProvider configurationProvider = new FormattersConfigurationProvider( + configFileResolver, + jsonEnvConfigResolver, + keyValueEnvironmentConfigurationResolverMock.Object, + new FormattersDisabledOverrideProvider(env)); configurationProvider.GetFormatterConfigurationByName("message").TryGetValue("outputFilePath", out var outputFilePathElement); var outputFilePath = outputFilePathElement!.ToString(); diff --git a/Tests/Reqnroll.RuntimeTests/EnvironmentWrapperStub.cs b/Tests/Reqnroll.RuntimeTests/EnvironmentWrapperStub.cs index ca0c39af1..fc0681af8 100644 --- a/Tests/Reqnroll.RuntimeTests/EnvironmentWrapperStub.cs +++ b/Tests/Reqnroll.RuntimeTests/EnvironmentWrapperStub.cs @@ -25,6 +25,8 @@ public IResult GetEnvironmentVariable(string name) ? Result.Success(value) : Result.Failure($"Environment variable '{name}' not set in stub"); + public IDictionary GetEnvironmentVariables(string prefix) => throw new NotSupportedException(); + public bool IsEnvironmentVariableSet(string name) => EnvironmentVariables.ContainsKey(name); diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs index 495817ba7..cbc80b4f4 100644 --- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/CucumberConfigurationTests.cs @@ -8,22 +8,23 @@ 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 _environmentResolverMock; private readonly FormattersConfigurationProvider _sut; public CucumberConfigurationTests() { _disableOverrideProviderMock = new Mock(); - _fileResolverMock = new Mock(); - _environmentResolverMock = new Mock(); - - var resolvers = new Dictionary - { - { "fileBasedResolver", _fileResolverMock.Object } - }; - - _sut = new FormattersConfigurationProvider(resolvers, _environmentResolverMock.Object, _disableOverrideProviderMock.Object); + _fileResolverMock = new Mock(); + _environmentResolverMock = new Mock(); + var keyValueEnvironmentConfigurationResolverMock = new Mock(); + keyValueEnvironmentConfigurationResolverMock.Setup(r => r.Resolve()).Returns(new Dictionary>()); + + _sut = new FormattersConfigurationProvider( + _fileResolverMock.Object, + _environmentResolverMock.Object, + keyValueEnvironmentConfigurationResolverMock.Object, + _disableOverrideProviderMock.Object); } [Fact] diff --git a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/EnvironmentConfigurationResolverTests.cs b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/JsonEnvironmentConfigurationResolverTests.cs similarity index 55% rename from Tests/Reqnroll.RuntimeTests/Formatters/Configuration/EnvironmentConfigurationResolverTests.cs rename to Tests/Reqnroll.RuntimeTests/Formatters/Configuration/JsonEnvironmentConfigurationResolverTests.cs index c5b6e6409..f9e9cf3f0 100644 --- a/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/EnvironmentConfigurationResolverTests.cs +++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/JsonEnvironmentConfigurationResolverTests.cs @@ -7,15 +7,15 @@ namespace Reqnroll.RuntimeTests.Formatters.Configuration; -public class EnvironmentConfigurationResolverTests +public class JsonEnvironmentConfigurationResolverTests { private readonly Mock _environmentWrapperMock; - private readonly EnvironmentConfigurationResolver _sut; + private readonly JsonEnvironmentConfigurationResolver _sut; - public EnvironmentConfigurationResolverTests() + public JsonEnvironmentConfigurationResolverTests() { _environmentWrapperMock = new Mock(); - _sut = new EnvironmentConfigurationResolver(_environmentWrapperMock.Object); + _sut = new JsonEnvironmentConfigurationResolver(_environmentWrapperMock.Object); } [Fact] @@ -56,6 +56,30 @@ public void Resolve_Should_Return_Configuration_From_Environment_Variables() result["formatter1"]["configSetting1"].Should().Be("configValue1"); } + + [Fact] + public void Resolve_should_return_configuration_with_case_insensitive_formatter_and_setting_names() + { + // Arrange + var json = """ + { + "formatters": { + "FORMATTER1": { + "CONFIGSetting1": "configValue1" } + } + } + """; + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariable(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE)) + .Returns(new Success(json)); + + // Act + var result = _sut.Resolve(); + + // Assert + result["formatter1"]["configSetting1"].Should().Be("configValue1"); + } + [Fact] public void Resolve_Should_Return_MultipleConfigurations_From_Environment_Variables() { @@ -81,4 +105,31 @@ public void Resolve_Should_Return_MultipleConfigurations_From_Environment_Variab Assert.Equal("forHtml", first["outputFilePath"]); Assert.Equal("forMessages", second["outputFilePath"]); } + + [Fact] + public void Resolve_Should_Parse_JSON_Format_Environment_Variable() + { + // Arrange + var expectedJson = """ + { + "formatters": { + "message": { + "outputFilePath": "foo.ndjson" + } + } + } + """; + + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariable(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE)) + .Returns(new Success(expectedJson)); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().ContainKey("message"); + 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 new file mode 100644 index 000000000..bdf0f154c --- /dev/null +++ b/Tests/Reqnroll.RuntimeTests/Formatters/Configuration/KeyValueEnvironmentConfigurationResolverTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Moq; +using Reqnroll.EnvironmentAccess; +using Reqnroll.Formatters.Configuration; +using Reqnroll.Formatters.RuntimeSupport; +using Xunit; + +namespace Reqnroll.RuntimeTests.Formatters.Configuration; + +public class KeyValueEnvironmentConfigurationResolverTests +{ + private readonly Mock _environmentWrapperMock; + private readonly Mock _formatterLogMock; + private readonly KeyValueEnvironmentConfigurationResolver _sut; + private const string SampleFormatterName = "sample"; + private const string SampleFormatterEnvironmentName = FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX + SampleFormatterName; + + public KeyValueEnvironmentConfigurationResolverTests() + { + _environmentWrapperMock = new Mock(); + _formatterLogMock = new Mock(); + _sut = new KeyValueEnvironmentConfigurationResolver(_environmentWrapperMock.Object, _formatterLogMock.Object); + } + + [Fact] + public void Constructor_Should_Throw_ArgumentNullException_When_EnvironmentWrapper_Is_Null() + { + // Act & Assert + var act = () => new KeyValueEnvironmentConfigurationResolver(null, _formatterLogMock.Object); + act.Should().Throw() + .WithParameterName("environmentWrapper"); + } + + [Fact] + public void Constructor_Should_Accept_Null_FormatterLog() + { + // Act & Assert + var act = () => new KeyValueEnvironmentConfigurationResolver(_environmentWrapperMock.Object); + act.Should().NotThrow(); + } + + [Fact] + public void Resolve_Should_Return_Empty_When_Environment_Variables_List_Is_Empty() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns(new Dictionary()); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void Resolve_should_ignore_formatter_with_empty_settings() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns( + new Dictionary + { + { SampleFormatterEnvironmentName, "" } + }); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void Resolve_should_configure_single_formatter_with_true_setting() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns( + new Dictionary + { + { SampleFormatterEnvironmentName, "true" } + }); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainKey(SampleFormatterName) + .WhoseValue.Should().BeEmpty(); + } + + [Fact] + public void Resolve_should_disable_formatter_with_false_setting() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns( + new Dictionary + { + { SampleFormatterEnvironmentName, "false" } + }); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainKey(SampleFormatterName) + .WhoseValue.Should().BeNull(); + } + + [Fact] + public void Resolve_should_throw_exception_with_invalid_settings() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns( + new Dictionary + { + { SampleFormatterEnvironmentName, "foo" } + }); + + // Act + FluentActions.Invoking(() => _sut.Resolve()) + .Should() + .Throw() + .WithMessage("*'foo'*"); + } + + [Fact] + public void Resolve_should_configure_single_formatter_with_output_file_settings() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns( + new Dictionary + { + { SampleFormatterEnvironmentName, "outputFilePath=foo.txt" } + }); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainKey(SampleFormatterName) + .WhoseValue.Should().Contain("outputFilePath", "foo.txt"); + } + + [Fact] + public void Resolve_should_configure_single_formatter_with_case_insensitive_settings() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns( + new Dictionary + { + { SampleFormatterEnvironmentName, "OUTPUTFilePath=foo.txt" } + }); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainKey(SampleFormatterName) + .WhoseValue.Should().Contain("outputFilePath", "foo.txt"); + } + + [Fact] + public void Resolve_should_configure_single_formatter_with_multiple_settings() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns( + new Dictionary + { + { SampleFormatterEnvironmentName, "setting1=value1;setting2=value2" } + }); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainKey(SampleFormatterName) + .WhoseValue.Should().Contain("setting1", "value1") + .And.Contain("setting2", "value2"); + } + + [Fact] + public void Resolve_should_trim_whitespaces_in_settings() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns( + new Dictionary + { + { SampleFormatterEnvironmentName, " setting1 = value1 ; setting2 = value2 " } + }); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().HaveCount(1); + result.Should().ContainKey(SampleFormatterName) + .WhoseValue.Should().Contain("setting1", "value1") + .And.Contain("setting2", "value2"); + } + + [Fact] + public void Resolve_should_configure_multiple_formatters() + { + // Arrange + _environmentWrapperMock + .Setup(e => e.GetEnvironmentVariables(FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX)) + .Returns( + new Dictionary + { + { FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX + "f1", "outputFilePath=foo.txt" }, + { FormattersConfigurationConstants.REQNROLL_FORMATTERS_ENVIRONMENT_VARIABLE_PREFIX + "f2", "outputFilePath=bar.txt" } + }); + + // Act + var result = _sut.Resolve(); + + // Assert + result.Should().HaveCount(2); + result.Should().ContainKey("f1") + .WhoseValue.Should().Contain("outputFilePath", "foo.txt"); + result.Should().ContainKey("f2") + .WhoseValue.Should().Contain("outputFilePath", "bar.txt"); + } +} \ No newline at end of file diff --git a/docs/installation/formatter-configuration.md b/docs/installation/formatter-configuration.md index 0cfd2a01d..21d17d6f2 100644 --- a/docs/installation/formatter-configuration.md +++ b/docs/installation/formatter-configuration.md @@ -128,7 +128,7 @@ You can override different parts of the output file path: ## Environment Variables -The settings discussed above can be overridden by setting an environment variable. When an environment variable is set, it takes precedence over the same configuration setting in the configuration file. If a setting is not overridden by an environment variable, the value will be taken from the configuration file (if set), otherwise a default (as shown above) will be used. +The settings discussed above can be overridden by setting an environment variable. When an environment variable is set, it takes precedence over the same configuration setting in the configuration file. If a setting is not overridden by an environment variable, the value will be taken from the configuration file (if set), otherwise a default (as shown above) will be used. The formatter specific environment variables override the general `REQNROLL_FORMATTERS` environment variable settings. ### Available Environment Variables @@ -151,6 +151,18 @@ export REQNROLL_FORMATTERS_DISABLED=true export REQNROLL_FORMATTERS_DISABLED=false ``` +#### REQNROLL_FORMATTERS_*formatter* + +**Description:** Overrides the configuration of a specific formatter using key-value pair settings. For example the `REQNROLL_FORMATTERS_HTML` environment variable can be used to configure the [html formatter](../reporting/reqnroll-formatters.md#html-formatter). + +**Default Value:** Not set (uses configuration file settings) + +**Behavior:** + +* When set to `true` it enables the formatter with default settings +* When set to `false` it disables the formatter (if it was configured in the configuration file) +* When set to `setting1=value1;setting2=value2` it configures the formatter with the specified settings and values. For example the value `outputFilePath=result.html` sets the output file to `result.html`. + #### REQNROLL_FORMATTERS **Description:** Overrides the `formatters` section of the `reqnroll.json` configuration file using JSON format. @@ -162,29 +174,43 @@ export REQNROLL_FORMATTERS_DISABLED=false ```{note} When using an environment variable to override a `formatters` section, the value of the environment variable must be properly escaped (appropriate to your shell) to remain a valid json representation of the configuration setting. ``` + ### Environment Variable Configuration Examples #### Enable HTML formatter with default settings: + ```bash -export REQNROLL_FORMATTERS='{"formatters": {"html": {}}}' +export REQNROLL_FORMATTERS_HTML='true' ``` -#### Enable Message formatter with default settings: +#### Enable HTML formatter with custom output path: + +```bash +export REQNROLL_FORMATTERS_HTML='outputFilePath=result.html' +``` + +#### Enable HTML formatter with custom directory only: + ```bash -export REQNROLL_FORMATTERS='{"formatters": {"message": {}}}' +export REQNROLL_FORMATTERS_HTML='outputFilePath=test-results/' ``` -#### Enable both formatters with custom output paths: +This setting will generate the HTML report in the specified folder with the default file name (`test-results/reqnroll_report.html`). + +#### Enable Message formatter with default settings: + ```bash -export REQNROLL_FORMATTERS='{"formatters": {"html": {"outputFilePath": "reports/test_report.html"}, "message": {"outputFilePath": "reports/test_messages.ndjson"}}}' +export REQNROLL_FORMATTERS_MESSAGE='true' ``` -#### Enable HTML formatter with custom directory only: +#### Enable both formatters with custom output paths using JSON: + ```bash -export REQNROLL_FORMATTERS='{"formatters": {"html": {"outputFilePath": "test-results/"}}}' +export REQNROLL_FORMATTERS='{"formatters": {"html": {"outputFilePath": "reports/test_report.html"}, "message": {"outputFilePath": "reports/test_messages.ndjson"}}}' ``` -#### Enable formatters with different configurations: +#### Set JSON value in different shells with correct escaping: + ```bash # Windows Command Prompt set REQNROLL_FORMATTERS={"formatters": {"html": {"outputFilePath": "output\\report.html"}, "message": {"outputFilePath": "output\\messages.ndjson"}}} diff --git a/docs/reporting/reqnroll-formatters.md b/docs/reporting/reqnroll-formatters.md index 472fe7140..ae4973c4b 100644 --- a/docs/reporting/reqnroll-formatters.md +++ b/docs/reporting/reqnroll-formatters.md @@ -6,7 +6,7 @@ Reqnroll formatters are only available in Reqnroll v3.0 or later. Reqnroll provides a *formatter* infrastructure, similar to [Cucumber formatters](https://cucumber.io/docs/cucumber/reporting/#built-in-reporter-plugins). The formatters can be used to generate reports of the test execution. Reqnroll provides built-in formatters ([HTML](#html-formatter), [Cucumber Messages](#cucumber-messages-formatter)) and can be extended with custom formatters. -In order to generate a report with a formatter, you need to enable it. You can enable multiple formatters as well. The easiest way to enable a formatter is to add a `formatters` section to the `reqnroll.json` configuration file. +In order to generate a report with a formatter, you need to enable it. You can enable multiple formatters as well. The easiest way to enable a formatter is to add a `formatters` section to the `reqnroll.json` configuration file or with environment variables. The following example enables the HTML formatter and configures the output file as `reqnroll_report.html`. @@ -21,6 +21,13 @@ The following example enables the HTML formatter and configures the output file } ``` +The same configuration can be achieved by setting an environment variable before running the tests. + +```{code-block} pwsh +$env:REQNROLL_FORMATTERS_HTML = 'outputFilePath=reqnroll_report.html' +dotnet test +``` + See [](../installation/formatter-configuration.md) for further details about formatter configuration. ## HTML formatter