diff --git a/Velopack.sln b/Velopack.sln index 53ae01e6..d2109101 100644 --- a/Velopack.sln +++ b/Velopack.sln @@ -42,7 +42,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AvaloniaCrossPlat", "exampl EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LegacyTestApp", "test\LegacyTestApp\LegacyTestApp.csproj", "{8B27C4BF-21B8-48B0-80F8-74520227C35F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VeloWpfSample", "examples\VeloWpfSample\VeloWpfSample.csproj", "{9E0F2B00-1B88-4B75-BEED-6DF8DBCA36B5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VeloWpfSample", "examples\VeloWpfSample\VeloWpfSample.csproj", "{9E0F2B00-1B88-4B75-BEED-6DF8DBCA36B5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Divergic.Logging.Xunit", "test\Divergic.Logging.Xunit\Divergic.Logging.Xunit.csproj", "{5ED2E9AF-101D-4D2D-B0B5-90A920EF692D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -102,6 +104,10 @@ Global {9E0F2B00-1B88-4B75-BEED-6DF8DBCA36B5}.Debug|Any CPU.Build.0 = Debug|Any CPU {9E0F2B00-1B88-4B75-BEED-6DF8DBCA36B5}.Release|Any CPU.ActiveCfg = Release|Any CPU {9E0F2B00-1B88-4B75-BEED-6DF8DBCA36B5}.Release|Any CPU.Build.0 = Release|Any CPU + {5ED2E9AF-101D-4D2D-B0B5-90A920EF692D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5ED2E9AF-101D-4D2D-B0B5-90A920EF692D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5ED2E9AF-101D-4D2D-B0B5-90A920EF692D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5ED2E9AF-101D-4D2D-B0B5-90A920EF692D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -114,6 +120,7 @@ Global {1FF6A262-13D0-45DF-B818-77AC84C52C6F} = {3EBFA551-780C-473D-A197-0EE56F2CBA82} {8B27C4BF-21B8-48B0-80F8-74520227C35F} = {7AC3A776-B582-4B65-9D03-BD52332B5CA3} {9E0F2B00-1B88-4B75-BEED-6DF8DBCA36B5} = {3EBFA551-780C-473D-A197-0EE56F2CBA82} + {5ED2E9AF-101D-4D2D-B0B5-90A920EF692D} = {7AC3A776-B582-4B65-9D03-BD52332B5CA3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {68CA987A-9BAB-4C75-8EEB-4596BA6BBD07} diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 6001fdc7..0f610849 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -37,12 +37,12 @@ + - all runtime; build; native; contentfiles; analyzers diff --git a/test/Divergic.Logging.Xunit/CacheLogger.cs b/test/Divergic.Logging.Xunit/CacheLogger.cs new file mode 100644 index 00000000..486586f7 --- /dev/null +++ b/test/Divergic.Logging.Xunit/CacheLogger.cs @@ -0,0 +1,156 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Linq; + using System.Threading; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class provides a cache of log entries written to the logger. + /// + public class CacheLogger : FilterLogger, ICacheLogger + { + private static readonly AsyncLocal> _scopes = + new AsyncLocal>(); + + private readonly ILoggerFactory? _factory; + private readonly IList _logEntries = new List(); + private readonly ILogger? _logger; + + /// + /// Creates a new instance of the class. + /// + public CacheLogger() + { + } + + /// + /// Creates a new instance of the class. + /// + /// The source logger. + /// The is null. + public CacheLogger(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + internal CacheLogger(ILogger logger, ILoggerFactory factory) + { + _logger = logger; + _factory = factory; + } + + /// + public override IDisposable? BeginScope(TState state) + { + var scope = _logger?.BeginScope(state) ?? NoopDisposable.Instance; + + var cacheScope = new CacheScope(scope, state, () => Scopes.TryPop(out _)); + + Scopes.Push(cacheScope); + + return cacheScope; + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + public override bool IsEnabled(LogLevel logLevel) + { + if (_logger == null) + { + return true; + } + + return _logger.IsEnabled(logLevel); + } + + /// + /// Disposes resources held by this instance. + /// + /// true if disposing unmanaged types; otherwise false. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _factory?.Dispose(); + } + } + + /// + protected override void WriteLogEntry( + LogLevel logLevel, + EventId eventId, + TState state, + string message, + Exception? exception, + Func formatter) + { + var entry = new LogEntry( + logLevel, + eventId, + state, + exception, + message, + Scopes.Select(s => s.State).ToArray()); + + _logEntries.Add(entry); + + _logger?.Log(logLevel, eventId, state, exception, formatter); + } + + /// + /// Gets the count of cached log entries. + /// + public int Count => _logEntries.Count; + + /// + /// Gets the cached log entries. + /// + public IReadOnlyCollection Entries => new ReadOnlyCollection(_logEntries); + + /// + /// Gets the last entry logged. + /// + public LogEntry? Last + { + get + { + var count = _logEntries.Count; + + if (count == 0) + { + return null; + } + + return _logEntries[count - 1]; + } + } + + private static ConcurrentStack Scopes + { + get + { + var scopes = _scopes.Value; + + if (scopes == null) + { + scopes = new ConcurrentStack(); + + _scopes.Value = scopes; + } + + return scopes; + } + } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/CacheLoggerT.cs b/test/Divergic.Logging.Xunit/CacheLoggerT.cs new file mode 100644 index 00000000..3f843f63 --- /dev/null +++ b/test/Divergic.Logging.Xunit/CacheLoggerT.cs @@ -0,0 +1,35 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class provides a cache logger for . + /// + /// The generic type of logger. + public class CacheLogger : CacheLogger, ICacheLogger + { + /// + /// Creates a new instance of the class. + /// + public CacheLogger() + { + } + + /// + /// Creates a new instance of the class. + /// + /// The source logger. + /// The is null. + public CacheLogger(ILogger logger) + : base(logger) + { + } + + internal CacheLogger(ILogger logger, ILoggerFactory factory) + : base(logger, factory) + { + } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/CacheScope.cs b/test/Divergic.Logging.Xunit/CacheScope.cs new file mode 100644 index 00000000..f592ece3 --- /dev/null +++ b/test/Divergic.Logging.Xunit/CacheScope.cs @@ -0,0 +1,28 @@ +namespace Divergic.Logging.Xunit +{ + using System; + + internal class CacheScope : IDisposable + { + private readonly Action _onScopeEnd; + private readonly IDisposable _scope; + + public CacheScope(IDisposable scope, object? state, Action onScopeEnd) + { + _scope = scope; + State = state; + _onScopeEnd = onScopeEnd; + } + + public void Dispose() + { + // Pass on the end scope request + _scope.Dispose(); + + // Clean up the scope in the cache logger + _onScopeEnd.Invoke(); + } + + public object? State { get; } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/DefaultFormatter.cs b/test/Divergic.Logging.Xunit/DefaultFormatter.cs new file mode 100644 index 00000000..64f95a9c --- /dev/null +++ b/test/Divergic.Logging.Xunit/DefaultFormatter.cs @@ -0,0 +1,93 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class provides the default formatting of log messages for xUnit test output. + /// + public class DefaultFormatter : ILogFormatter + { + private readonly LoggingConfig _config; + + /// + /// Initializes a new instance of the class. + /// + /// The logging configuration. + /// The value is null. + public DefaultFormatter(LoggingConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + /// + public virtual string Format( + int scopeLevel, + string categoryName, + LogLevel logLevel, + EventId eventId, + string message, + Exception? exception) + { + var padding = new string(' ', scopeLevel * _config.ScopePaddingSpaces); + var parts = new List(2); + + if (string.IsNullOrWhiteSpace(message) == false) + { + var part = string.Format(CultureInfo.InvariantCulture, FormatMask, padding, logLevel, eventId.Id, + message); + + part = MaskSensitiveValues(part); + + parts.Add(part); + } + + if (exception != null) + { + var part = string.Format( + CultureInfo.InvariantCulture, + FormatMask, + padding, + logLevel, + eventId.Id, + exception); + + part = MaskSensitiveValues(part); + + parts.Add(part); + } + + return string.Join(Environment.NewLine, parts); + } + + private string MaskSensitiveValues(string value) + { + const string mask = "****"; + + for (var index = 0; index < _config.SensitiveValues.Count; index++) + { + var sensitiveValue = _config.SensitiveValues[index]; + + value = value.Replace(sensitiveValue, mask); + } + + return value; + } + + /// + /// Returns the string format mask used to generate a log message. + /// + /// The format values are: + ///
    + ///
  • 0: Padding
  • + ///
  • 1: Level
  • + ///
  • 2: Event Id
  • + ///
  • 3: Message
  • + ///
+ ///
+ protected virtual string FormatMask { get; } = "{0}{1} [{2}]: {3}"; + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/DefaultScopeFormatter.cs b/test/Divergic.Logging.Xunit/DefaultScopeFormatter.cs new file mode 100644 index 00000000..144cb017 --- /dev/null +++ b/test/Divergic.Logging.Xunit/DefaultScopeFormatter.cs @@ -0,0 +1,19 @@ +namespace Divergic.Logging.Xunit; + +/// +/// The +/// class is used to provide log message formatting logic for scope beginning and end messages. +/// +public class DefaultScopeFormatter : DefaultFormatter +{ + /// + /// Initializes a new instance of the class. + /// + /// The logging configuration. + public DefaultScopeFormatter(LoggingConfig config) : base(config) + { + } + + /// + protected override string FormatMask { get; } = "{0}{3}"; +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/Divergic.Logging.Xunit.csproj b/test/Divergic.Logging.Xunit/Divergic.Logging.Xunit.csproj new file mode 100644 index 00000000..86ee3a35 --- /dev/null +++ b/test/Divergic.Logging.Xunit/Divergic.Logging.Xunit.csproj @@ -0,0 +1,17 @@ + + + + net48;net8.0 + latest + enable + True + ..\..\Velopack.snk + + + + + + + + + \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/FilterLogger.cs b/test/Divergic.Logging.Xunit/FilterLogger.cs new file mode 100644 index 00000000..87c43df1 --- /dev/null +++ b/test/Divergic.Logging.Xunit/FilterLogger.cs @@ -0,0 +1,113 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class provides common filtering logic to ensure log records are only written for enabled loggers where there is a + /// formatted message and/or exception to log. + /// + public abstract class FilterLogger : ILogger + { + private readonly string _nullFormatted = "[null]"; + + /// + public abstract IDisposable? BeginScope(TState state) where TState : notnull; + + /// + public abstract bool IsEnabled(LogLevel logLevel); + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); + + if (IsEnabled(logLevel) == false) + { + return; + } + + var formattedMessage = FormatMessage(state, exception, formatter); + + if (ShouldFilter(formattedMessage, exception)) + { + return; + } + + WriteLogEntry(logLevel, eventId, state, formattedMessage, exception, formatter); + } + + /// + /// Returns the formatted log message. + /// + /// The type of state data to log. + /// The state data to log. + /// The exception to log. + /// The formatter that creates the log message. + /// The log message. + protected string FormatMessage( + TState state, + Exception? exception, + Func formatter) + { + formatter = formatter ?? throw new ArgumentNullException(nameof(formatter)); + +#pragma warning disable CA1062 // Validate arguments of public methods + var formattedMessage = formatter(state, exception); +#pragma warning restore CA1062 // Validate arguments of public methods + + // Clear the message if it looks like a null formatted message + if (formattedMessage == _nullFormatted) + { + return string.Empty; + } + + return formattedMessage; + } + + /// + /// Determines whether the log message should be filtered and not written. + /// + /// The message to log. + /// The exception to log. + /// true if the log should not be written; otherwise false. + protected virtual bool ShouldFilter(string message, Exception? exception) + { + if (exception != null) + { + return false; + } + + if (string.IsNullOrWhiteSpace(message)) + { + return true; + } + + return false; + } + + /// + /// Writes the log entry with the specified values. + /// + /// The type of state data to log. + /// The log level. + /// The event id. + /// The state data to log. + /// The formatted message. + /// The exception to log. + /// The formatter that creates the log message. + protected abstract void WriteLogEntry( + LogLevel logLevel, + EventId eventId, + TState state, + string message, + Exception? exception, + Func formatter); + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/ICacheLogger.cs b/test/Divergic.Logging.Xunit/ICacheLogger.cs new file mode 100644 index 00000000..586935c0 --- /dev/null +++ b/test/Divergic.Logging.Xunit/ICacheLogger.cs @@ -0,0 +1,28 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using System.Collections.Generic; + using Microsoft.Extensions.Logging; + + /// + /// The + /// interface defines the members for recording and accessing log entries. + /// + public interface ICacheLogger : ILogger, IDisposable + { + /// + /// Gets the number of cache entries recorded. + /// + int Count { get; } + + /// + /// Gets the recorded cache entries. + /// + IReadOnlyCollection Entries { get; } + + /// + /// Gets the latest cache entry. + /// + LogEntry? Last { get; } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/ICacheLoggerT.cs b/test/Divergic.Logging.Xunit/ICacheLoggerT.cs new file mode 100644 index 00000000..54b475ba --- /dev/null +++ b/test/Divergic.Logging.Xunit/ICacheLoggerT.cs @@ -0,0 +1,13 @@ +namespace Divergic.Logging.Xunit +{ + using Microsoft.Extensions.Logging; + + /// + /// The + /// interface defines the members for recording and accessing log entries. + /// + /// The type of class using the cache. + public interface ICacheLogger : ICacheLogger, ILogger + { + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/ILogFormatter.cs b/test/Divergic.Logging.Xunit/ILogFormatter.cs new file mode 100644 index 00000000..243d21ca --- /dev/null +++ b/test/Divergic.Logging.Xunit/ILogFormatter.cs @@ -0,0 +1,30 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using Microsoft.Extensions.Logging; + + /// + /// The + /// interface defines the members for formatting log messages. + /// + public interface ILogFormatter + { + /// + /// Formats the log message with the specified values. + /// + /// The number of active logging scopes. + /// The logger name. + /// The log level. + /// The event id. + /// The log message. + /// The exception to be logged. + /// The formatted log message. + string Format( + int scopeLevel, + string categoryName, + LogLevel logLevel, + EventId eventId, + string message, + Exception? exception); + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/LogEntry.cs b/test/Divergic.Logging.Xunit/LogEntry.cs new file mode 100644 index 00000000..260455c6 --- /dev/null +++ b/test/Divergic.Logging.Xunit/LogEntry.cs @@ -0,0 +1,68 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using System.Collections.Generic; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class is used to identify the data related to a log entry. + /// + public class LogEntry + { + /// + /// Initializes a new instance of the class. + /// + /// The log level. + /// The event id. + /// The state. + /// The exception. + /// The message. + /// The currently active scopes. + public LogEntry( + LogLevel logLevel, + EventId eventId, + object? state, + Exception? exception, + string message, + IReadOnlyCollection scopes) + { + LogLevel = logLevel; + EventId = eventId; + State = state; + Exception = exception; + Message = message; + Scopes = scopes; + } + + /// + /// Gets the event id of the entry. + /// + public EventId EventId { get; } + + /// + /// Gets the exception of the entry. + /// + public Exception? Exception { get; } + + /// + /// Gets the log level of the entry. + /// + public LogLevel LogLevel { get; } + + /// + /// Gets the message of the entry. + /// + public string Message { get; } + + /// + /// Gets the scopes active at the time of the call to + /// + public IReadOnlyCollection Scopes { get; } + + /// + /// Gets the state of the entry. + /// + public object? State { get; } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/LogFactory.cs b/test/Divergic.Logging.Xunit/LogFactory.cs new file mode 100644 index 00000000..7cd9970a --- /dev/null +++ b/test/Divergic.Logging.Xunit/LogFactory.cs @@ -0,0 +1,32 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using global::Xunit.Abstractions; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class is used to create instances. + /// + public static class LogFactory + { + /// + /// Creates an instance that is configured for xUnit output. + /// + /// The test output. + /// Optional logging configuration. + /// The logger factory. + /// The is null. + public static ILoggerFactory Create( + ITestOutputHelper output, LoggingConfig? config = null) + { + output = output ?? throw new ArgumentNullException(nameof(output)); + + var factory = new LoggerFactory(); + + factory.AddXunit(output, config); + + return factory; + } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/LoggerExtensions.cs b/test/Divergic.Logging.Xunit/LoggerExtensions.cs new file mode 100644 index 00000000..a9ea0484 --- /dev/null +++ b/test/Divergic.Logging.Xunit/LoggerExtensions.cs @@ -0,0 +1,57 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class provides extension methods for wrapping instances in . + /// + public static class LoggerExtensions + { + /// + /// Returns a for the specified logger. + /// + /// The source logger. + /// The cache logger. + /// The is null. + public static ICacheLogger WithCache(this ILogger logger) + { + logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + var cacheLogger = new CacheLogger(logger); + + return cacheLogger; + } + + /// + /// Returns a for the specified logger. + /// + /// The type of generic logger. + /// The source logger. + /// The cache logger. + /// The is null. + public static ICacheLogger WithCache(this ILogger logger) + { + logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + var cacheLogger = new CacheLogger(logger); + + return cacheLogger; + } + + internal static ICacheLogger WithCache(this ILogger logger, ILoggerFactory factory) + { + var cacheLogger = new CacheLogger(logger, factory); + + return cacheLogger; + } + + internal static ICacheLogger WithCache(this ILogger logger, ILoggerFactory factory) + { + var cacheLogger = new CacheLogger(logger, factory); + + return cacheLogger; + } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/LoggerFactoryExtensions.cs b/test/Divergic.Logging.Xunit/LoggerFactoryExtensions.cs new file mode 100644 index 00000000..e79f94d4 --- /dev/null +++ b/test/Divergic.Logging.Xunit/LoggerFactoryExtensions.cs @@ -0,0 +1,40 @@ +namespace Microsoft.Extensions.Logging +{ + using System; + using Divergic.Logging.Xunit; + using Xunit.Abstractions; + + /// + /// The + /// class provides extension methods for configuring with providers. + /// + public static class LoggerFactoryExtensions + { + /// + /// Registers the in the factory using the specified + /// . + /// + /// The factory to add the provider to. + /// The test output reference. + /// Optional logging configuration. + /// The logger factory. + /// The is null. + /// The is null. + public static ILoggerFactory AddXunit(this ILoggerFactory factory, ITestOutputHelper output, + LoggingConfig? config = null) + { + factory = factory ?? throw new ArgumentNullException(nameof(factory)); + output = output ?? throw new ArgumentNullException(nameof(output)); + +#pragma warning disable CA2000 // Dispose objects before losing scope + var provider = new TestOutputLoggerProvider(output, config); +#pragma warning restore CA2000 // Dispose objects before losing scope + +#pragma warning disable CA1062 // Validate arguments of public methods + factory.AddProvider(provider); +#pragma warning restore CA1062 // Validate arguments of public methods + + return factory; + } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/LoggingBuilderExtensions.cs b/test/Divergic.Logging.Xunit/LoggingBuilderExtensions.cs new file mode 100644 index 00000000..d8cb713a --- /dev/null +++ b/test/Divergic.Logging.Xunit/LoggingBuilderExtensions.cs @@ -0,0 +1,34 @@ +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.Logging +{ + using System; + using Divergic.Logging.Xunit; + using Xunit.Abstractions; + + /// + /// The + /// class provides extension methods for the interface. + /// + public static class LoggingBuilderExtensions + { + /// + /// Adds a logger to writes to the xUnit test output to the specified logging builder. + /// + /// The logging builder. + /// The xUnit test output helper. + /// Optional logging configuration. + public static void AddXunit(this ILoggingBuilder builder, ITestOutputHelper output, + LoggingConfig? config = null) + { + builder = builder ?? throw new ArgumentNullException(nameof(builder)); + output = output ?? throw new ArgumentNullException(nameof(output)); + + // Object is added as a provider to the builder and cannot be disposed of here +#pragma warning disable CA2000 // Dispose objects before losing scope + var provider = new TestOutputLoggerProvider(output, config); +#pragma warning restore CA2000 // Dispose objects before losing scope + + builder.AddProvider(provider); + } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/LoggingConfig.cs b/test/Divergic.Logging.Xunit/LoggingConfig.cs new file mode 100644 index 00000000..d5536937 --- /dev/null +++ b/test/Divergic.Logging.Xunit/LoggingConfig.cs @@ -0,0 +1,65 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using System.Collections.ObjectModel; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class is used to configure how logging operates. + /// + public class LoggingConfig + { + private ILogFormatter _formatter; + private ILogFormatter _scopeFormatter; + + /// + /// Initializes a new instance of the class. + /// + public LoggingConfig() + { + _formatter = new DefaultFormatter(this); + _scopeFormatter = new DefaultScopeFormatter(this); + } + + /// + /// Gets or sets a custom formatting for rendering log messages to xUnit test output. + /// + public ILogFormatter Formatter + { + get => _formatter; + set => + _formatter = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Gets or sets whether exceptions thrown while logging outside of the test execution will be ignored. + /// + public bool IgnoreTestBoundaryException { get; set; } + + /// + /// Gets or sets the minimum logging level. + /// + public LogLevel LogLevel { get; set; } = LogLevel.Trace; + + /// + /// Gets or sets a custom formatting for rendering scope beginning and end messages to xUnit test output. + /// + public ILogFormatter ScopeFormatter + { + get => _scopeFormatter; + set => + _scopeFormatter = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Identifies the number of spaces to use for indenting scopes. + /// + public int ScopePaddingSpaces { get; set; } = 3; + + /// + /// Gets the set of sensitive values that should be filtered out when writing log messages. + /// + public Collection SensitiveValues { get; } = new(); + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/LoggingTestsBase.cs b/test/Divergic.Logging.Xunit/LoggingTestsBase.cs new file mode 100644 index 00000000..a6c1c835 --- /dev/null +++ b/test/Divergic.Logging.Xunit/LoggingTestsBase.cs @@ -0,0 +1,64 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using global::Xunit.Abstractions; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class is used to provide a simple logging bootstrap class for xUnit test classes. + /// + public abstract class LoggingTestsBase : IDisposable + { + /// + /// Initializes a new instance of the class. + /// + /// The xUnit test output. + /// The minimum log level to output. + protected LoggingTestsBase(ITestOutputHelper output, LogLevel logLevel) + { + Output = output ?? throw new ArgumentNullException(nameof(output)); + Logger = output.BuildLogger(logLevel); + } + + /// + /// Initializes a new instance of the class. + /// + /// The xUnit test output. + /// Optional logging configuration. + protected LoggingTestsBase(ITestOutputHelper output, LoggingConfig? config = null) + { + Output = output ?? throw new ArgumentNullException(nameof(output)); + Logger = output.BuildLogger(config); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes resources held by this instance. + /// + /// true if disposing unmanaged types; otherwise false. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + Logger.Dispose(); + } + } + + /// + /// Gets the logger instance. + /// + protected ICacheLogger Logger { get; } + + /// + /// Gets the xUnit test output. + /// + protected ITestOutputHelper Output { get; } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/LoggingTestsBaseT.cs b/test/Divergic.Logging.Xunit/LoggingTestsBaseT.cs new file mode 100644 index 00000000..e1179ae9 --- /dev/null +++ b/test/Divergic.Logging.Xunit/LoggingTestsBaseT.cs @@ -0,0 +1,64 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using global::Xunit.Abstractions; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class is used to provide a simple logging bootstrap class for xUnit test classes. + /// + public abstract class LoggingTestsBase : IDisposable + { + /// + /// Initializes a new instance of the class. + /// + /// The xUnit test output. + /// The minimum log level to output. + protected LoggingTestsBase(ITestOutputHelper output, LogLevel logLevel) + { + Output = output ?? throw new ArgumentNullException(nameof(output)); + Logger = output.BuildLoggerFor(logLevel); + } + + /// + /// Initializes a new instance of the class. + /// + /// The xUnit test output. + /// Optional logging configuration. + protected LoggingTestsBase(ITestOutputHelper output, LoggingConfig? config = null) + { + Output = output ?? throw new ArgumentNullException(nameof(output)); + Logger = output.BuildLoggerFor(config); + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes resources held by this instance. + /// + /// true if disposing unmanaged types; otherwise false. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + Logger.Dispose(); + } + } + + /// + /// Gets the logger instance. + /// + protected ICacheLogger Logger { get; } + + /// + /// Gets the xUnit test output. + /// + protected ITestOutputHelper Output { get; } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/NoopDisposable.cs b/test/Divergic.Logging.Xunit/NoopDisposable.cs new file mode 100644 index 00000000..6592379a --- /dev/null +++ b/test/Divergic.Logging.Xunit/NoopDisposable.cs @@ -0,0 +1,14 @@ +namespace Divergic.Logging.Xunit +{ + using System; + + internal class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new NoopDisposable(); + + public void Dispose() + { + // No-op + } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/ScopeWriter.cs b/test/Divergic.Logging.Xunit/ScopeWriter.cs new file mode 100644 index 00000000..cf2dcf67 --- /dev/null +++ b/test/Divergic.Logging.Xunit/ScopeWriter.cs @@ -0,0 +1,129 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using System.Diagnostics; + using System.Globalization; + using System.Text.Json; + using global::Xunit.Abstractions; + using Microsoft.Extensions.Logging; + + internal class ScopeWriter : IDisposable + { + private readonly string _category; + private readonly LoggingConfig _config; + private readonly int _depth; + private readonly Action _onScopeEnd; + private readonly ITestOutputHelper _output; + private readonly object? _state; + private string _scopeMessage = string.Empty; + private string _structuredStateData = string.Empty; + + public ScopeWriter( + ITestOutputHelper output, + object? state, + int depth, + string category, + Action onScopeEnd, + LoggingConfig config) + { + _output = output; + _state = state; + _depth = depth; + _category = category; + _onScopeEnd = onScopeEnd; + _config = config; + + DetermineScopeStateMessage(); + + var scopeStartMessage = BuildScopeStateMessage(false); + + WriteLog(_depth, scopeStartMessage); + + if (string.IsNullOrWhiteSpace(_structuredStateData) == false) + { + // Add the padding to the structured data + var structuredLines = + _structuredStateData.Split(new[] {Environment.NewLine}, StringSplitOptions.RemoveEmptyEntries); + + WriteLog(_depth + 1, "Scope data: "); + + foreach (var structuredLine in structuredLines) + { + WriteLog(_depth + 1, structuredLine); + } + } + } + + private void WriteLog(int depth, string message) + { + var formattedMessage = _config.ScopeFormatter.Format(depth, _category, LogLevel.Information, 0, message, null); + + _output.WriteLine(formattedMessage); + + // Write the message to the output window + Trace.WriteLine(formattedMessage); + } + + public void Dispose() + { + var scopeStartMessage = BuildScopeStateMessage(true); + + _output.WriteLine(scopeStartMessage); + + _onScopeEnd.Invoke(); + } + + private string BuildScopeStateMessage(bool isScopeEnd) + { + var endScopeMarker = isScopeEnd ? "/" : string.Empty; + const string format = "<{0}{1}>"; + + var message = string.Format(CultureInfo.InvariantCulture, format, endScopeMarker, _scopeMessage); + + var formattedMessage = + _config.ScopeFormatter.Format(_depth, _category, LogLevel.Information, 0, message, null); + + return formattedMessage; + } + + private void DetermineScopeStateMessage() + { + const string scopeMarker = "Scope: "; + var defaultScopeMessage = "Scope " + (_depth + 1); + + if (_state == null) + { + _scopeMessage = defaultScopeMessage; + } + else if (_state is string state) + { + if (string.IsNullOrWhiteSpace(state)) + { + _scopeMessage = defaultScopeMessage; + } + else + { + _scopeMessage = scopeMarker + state; + } + } + else if (_state.GetType().IsValueType) + { + _scopeMessage = scopeMarker + _state; + } + else + { + try + { + // The data is probably a complex object or a structured log entry + _structuredStateData = JsonSerializer.Serialize(_state, SerializerSettings.Default); + } + catch (JsonException ex) + { + _structuredStateData = ex.ToString(); + } + + _scopeMessage = defaultScopeMessage; + } + } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/SerializerSettings.cs b/test/Divergic.Logging.Xunit/SerializerSettings.cs new file mode 100644 index 00000000..c7583455 --- /dev/null +++ b/test/Divergic.Logging.Xunit/SerializerSettings.cs @@ -0,0 +1,28 @@ +namespace Divergic.Logging.Xunit +{ + using System.Text.Json; + using System.Text.Json.Serialization; + + /// + /// The + /// class provides access to settings for JSON serialization. + /// + public static class SerializerSettings + { + private static JsonSerializerOptions BuildSerializerSettings() + { + var settings = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + return settings; + } + + /// + /// Gets the default serializer settings. + /// + public static JsonSerializerOptions Default { get; } = BuildSerializerSettings(); + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/TestOutputHelperExtensions.cs b/test/Divergic.Logging.Xunit/TestOutputHelperExtensions.cs new file mode 100644 index 00000000..f437a08e --- /dev/null +++ b/test/Divergic.Logging.Xunit/TestOutputHelperExtensions.cs @@ -0,0 +1,183 @@ +// ReSharper disable once CheckNamespace +namespace Xunit.Abstractions +{ + using System; + using System.Runtime.CompilerServices; + using Divergic.Logging.Xunit; + using Microsoft.Extensions.Logging; + + /// + /// The class provides extension methods for the + /// . + /// + public static class TestOutputHelperExtensions + { + /// + /// Builds a logger from the specified test output helper. + /// + /// The test output helper. + /// + /// The member to create the logger for. This is automatically populated using + /// . + /// + /// The logger. + /// The is null. + public static ICacheLogger BuildLogger( + this ITestOutputHelper output, + [CallerMemberName] string memberName = "") + { + return BuildLogger(output, null, memberName); + } + + /// + /// Builds a logger from the specified test output helper. + /// + /// The test output helper. + /// The minimum log level to output. + /// + /// The member to create the logger for. This is automatically populated using + /// . + /// + /// The logger. + /// The is null. + public static ICacheLogger BuildLogger( + this ITestOutputHelper output, + LogLevel logLevel, + [CallerMemberName] string memberName = "") + { + var config = new LoggingConfig + { + LogLevel = logLevel + }; + + return BuildLogger(output, config, memberName); + } + + /// + /// Builds a logger from the specified test output helper. + /// + /// The test output helper. + /// Optional logging configuration. + /// + /// The member to create the logger for. This is automatically populated using + /// . + /// + /// The logger. + /// The is null. + public static ICacheLogger BuildLogger( + this ITestOutputHelper output, + LoggingConfig? config, + [CallerMemberName] string memberName = "") + { + output = output ?? throw new ArgumentNullException(nameof(output)); + + // Do not use the using keyword here because the factory will be disposed before the cache logger is finished with it + var factory = LogFactory.Create(output, config); + + var logger = factory.CreateLogger(memberName); + + return logger.WithCache(factory); + } + + /// + /// Builds a logger from the specified test output helper for the specified type. + /// + /// The type to create the logger for. + /// The test output helper. + /// The logger. + /// The is null. + public static ICacheLogger BuildLoggerFor(this ITestOutputHelper output) + { + return BuildLoggerFor(output, null); + } + + /// + /// Builds a logger from the specified test output helper for the specified type. + /// + /// The type to create the logger for. + /// The test output helper. + /// The minimum log level to output. + /// The logger. + /// The is null. + public static ICacheLogger BuildLoggerFor(this ITestOutputHelper output, LogLevel logLevel) + { + var config = new LoggingConfig + { + LogLevel = logLevel + }; + + return BuildLoggerFor(output, config); + } + + /// + /// Builds a logger from the specified test output helper for the specified type. + /// + /// The type to create the logger for. + /// The test output helper. + /// Optional logging configuration. + /// The logger. + /// The is null. + public static ICacheLogger BuildLoggerFor(this ITestOutputHelper output, LoggingConfig? config) + { + output = output ?? throw new ArgumentNullException(nameof(output)); + + // Do not use the using keyword here because the factory will be disposed before the cache logger is finished with it + var factory = LogFactory.Create(output, config); + + var logger = factory.CreateLogger(); + + return logger.WithCache(factory); + } + + /// + /// Builds a logger factory from the specified test output helper. + /// + /// The test output helper. + /// The logger factory. + /// The is null. + public static ILoggerFactory BuildLoggerFactory( + this ITestOutputHelper output) + { + return BuildLoggerFactory(output, null); + } + + /// + /// Builds a logger factory from the specified test output helper. + /// + /// The test output helper. + /// The minimum log level to output. + /// The logger factory. + /// The is null. + public static ILoggerFactory BuildLoggerFactory( + this ITestOutputHelper output, + LogLevel logLevel) + { + var config = new LoggingConfig + { + LogLevel = logLevel + }; + + return BuildLoggerFactory(output, config); + } + + /// + /// Builds a logger factory from the specified test output helper. + /// + /// The test output helper. + /// Optional logging configuration. + /// The logger factory. + /// The is null. + public static ILoggerFactory BuildLoggerFactory( + this ITestOutputHelper output, + LoggingConfig? config) + { + output = output ?? throw new ArgumentNullException(nameof(output)); + + // Do not use the using keyword here because the factory will be disposed before the cache logger is finished with it + var factory = LogFactory.Create(output, config); + + return factory; + } + + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/TestOutputLogger.cs b/test/Divergic.Logging.Xunit/TestOutputLogger.cs new file mode 100644 index 00000000..eb94c80a --- /dev/null +++ b/test/Divergic.Logging.Xunit/TestOutputLogger.cs @@ -0,0 +1,113 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using System.Collections.Concurrent; + using System.Diagnostics; + using System.Threading; + using global::Xunit.Abstractions; + using Microsoft.Extensions.Logging; + + /// + /// The + /// class is used to provide logging implementation for Xunit. + /// + public class TestOutputLogger : FilterLogger + { + private static readonly AsyncLocal> _scopes = + new AsyncLocal>(); + + private readonly LoggingConfig _config; + private readonly string _categoryName; + private readonly ITestOutputHelper _output; + + /// + /// Creates a new instance of the class. + /// + /// The category name of the logger. + /// The test output helper. + /// Optional logging configuration. + /// The is null, empty or whitespace. + /// The is null. + public TestOutputLogger(string categoryName, ITestOutputHelper output, LoggingConfig? config = null) + { + if (string.IsNullOrWhiteSpace(categoryName)) + { + throw new ArgumentException("No name value has been supplied", nameof(categoryName)); + } + + _categoryName = categoryName; + _output = output ?? throw new ArgumentNullException(nameof(output)); + _config = config ?? new LoggingConfig(); + } + + /// + public override IDisposable BeginScope(TState state) + { + var scopeWriter = new ScopeWriter(_output, state, Scopes.Count, _categoryName, () => Scopes.TryPop(out _), _config); + + Scopes.Push(scopeWriter); + + return scopeWriter; + } + + /// + public override bool IsEnabled(LogLevel logLevel) + { + if (logLevel == LogLevel.None) + { + return false; + } + + return logLevel >= _config.LogLevel; + } + + /// + protected override void WriteLogEntry( + LogLevel logLevel, + EventId eventId, + TState state, + string message, + Exception? exception, + Func formatter) + { + try + { + WriteLog(logLevel, eventId, message, exception); + } + catch (InvalidOperationException) + { + if (_config.IgnoreTestBoundaryException == false) + { + throw; + } + } + } + + private void WriteLog(LogLevel logLevel, EventId eventId, string message, Exception? exception) + { + var formattedMessage = _config.Formatter.Format(Scopes.Count, _categoryName, logLevel, eventId, message, exception); + + _output.WriteLine(formattedMessage); + + // Write the message to the output window + Trace.WriteLine(formattedMessage); + } + + private static ConcurrentStack Scopes + { + get + { + var scopes = _scopes.Value; + + if (scopes == null) + { + scopes = new ConcurrentStack(); + + _scopes.Value = scopes; + } + + return scopes; + } + } + } +} \ No newline at end of file diff --git a/test/Divergic.Logging.Xunit/TestOutputLoggerProvider.cs b/test/Divergic.Logging.Xunit/TestOutputLoggerProvider.cs new file mode 100644 index 00000000..2f368cee --- /dev/null +++ b/test/Divergic.Logging.Xunit/TestOutputLoggerProvider.cs @@ -0,0 +1,46 @@ +namespace Divergic.Logging.Xunit +{ + using System; + using global::Xunit.Abstractions; + using Microsoft.Extensions.Logging; + + /// + /// The class is used to provide Xunit logging to + /// . + /// + public sealed class TestOutputLoggerProvider : ILoggerProvider + { + private readonly LoggingConfig? _config; + private readonly ITestOutputHelper _output; + + /// + /// Initializes a new instance of the class. + /// + /// The test output helper. + /// Optional logging configuration. + /// The is null. + public TestOutputLoggerProvider(ITestOutputHelper output, LoggingConfig? config = null) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + _config = config; + } + + /// + /// The is null, empty or whitespace. + public ILogger CreateLogger(string categoryName) + { + if (string.IsNullOrWhiteSpace(categoryName)) + { + throw new ArgumentException("No categoryName value has been supplied", nameof(categoryName)); + } + + return new TestOutputLogger(categoryName, _output, _config); + } + + /// + public void Dispose() + { + // no-op + } + } +} \ No newline at end of file diff --git a/test/Velopack.Tests/Velopack.Tests.csproj b/test/Velopack.Tests/Velopack.Tests.csproj index e394d036..5a59ad47 100644 --- a/test/Velopack.Tests/Velopack.Tests.csproj +++ b/test/Velopack.Tests/Velopack.Tests.csproj @@ -23,7 +23,6 @@ -