diff --git a/test/Directory.Build.targets b/test/Directory.Build.targets index 4c92c1dc..c95cdac5 100644 --- a/test/Directory.Build.targets +++ b/test/Directory.Build.targets @@ -4,7 +4,7 @@ false false $(NoWarn);NU1902;NU1903 - 17.8.0 + 17.11.1 @@ -20,7 +20,7 @@ - + diff --git a/test/MockHttp.Server.Tests/Fixtures/CapturingLoggerFactoryFixture.cs b/test/MockHttp.Server.Tests/Fixtures/CapturingLoggerFactoryFixture.cs new file mode 100644 index 00000000..2ff20365 --- /dev/null +++ b/test/MockHttp.Server.Tests/Fixtures/CapturingLoggerFactoryFixture.cs @@ -0,0 +1,230 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; +using Microsoft.Extensions.Options; + +namespace MockHttp.Fixtures; + +public delegate void CaptureDelegate(string message); + +public class CapturingLoggerFactoryFixture : LoggerFactoryFixture +{ + private static readonly AsyncLocal LogContextLocal = new(); + + public CapturingLoggerFactoryFixture() + : base(configure => configure + .AddConsole(opts => opts.FormatterName = ConsoleCapture.NameKey) + .AddConsoleFormatter(opts => opts.IncludeScopes = true) + .Services.AddSingleton((CaptureDelegate)(message => LogContextLocal.Value?.Events.Add(message))) + ) + { + } + + public static LogContext CreateContext() + { + return LogContextLocal.Value = new LogContext(() => LogContextLocal.Value = null); + } + + public sealed class LogContext(Action dispose) : IDisposable + { + public List Events { get; } = new(); + + public void Dispose() + { + dispose(); + } + } + + private class ConsoleCapture : ConsoleFormatter + { + internal const string NameKey = "console-capture"; + private const string LogLevelPadding = ": "; + private static readonly string MessagePadding = new(' ', GetLogLevelString(LogLevel.Information).Length + LogLevelPadding.Length); + private static readonly string NewLineWithMessagePadding = Environment.NewLine + MessagePadding; + + private readonly CaptureDelegate _onWrite; + + private readonly ConsoleCaptureOptions _options; + + public ConsoleCapture + ( + CaptureDelegate onWrite, + IOptions options + ) : base(NameKey) + { + _onWrite = onWrite ?? throw new ArgumentNullException(nameof(onWrite)); + _options = options.Value; + } + + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) + { + var sb = new StringBuilder(); + textWriter = new StringWriter(sb); + + string? message = logEntry.Formatter(logEntry.State, logEntry.Exception); + if (logEntry.Exception is null && message is null) + { + return; + } + + LogLevel logLevel = logEntry.LogLevel; + string logLevelString = GetLogLevelString(logLevel); + + string? timestamp = null; + string? timestampFormat = _options.TimestampFormat; + if (timestampFormat is not null) + { + DateTimeOffset dateTimeOffset = GetCurrentDateTime(); + timestamp = dateTimeOffset.ToString(timestampFormat); + } + + if (!string.IsNullOrEmpty(timestamp)) + { + textWriter.Write(timestamp); + } + + if (!string.IsNullOrEmpty(logLevelString)) + { + textWriter.Write(logLevelString); + } + + CreateDefaultLogMessage(textWriter, in logEntry, message, scopeProvider); + + _onWrite(sb.ToString()); + } + + private void CreateDefaultLogMessage(TextWriter textWriter, in LogEntry logEntry, string message, IExternalScopeProvider? scopeProvider) + { + bool singleLine = _options.SingleLine; + int eventId = logEntry.EventId.Id; + Exception? exception = logEntry.Exception; + + // Example: + // info: ConsoleApp.Program[10] + // Request received + + // category and event id + textWriter.Write(LogLevelPadding); + textWriter.Write(logEntry.Category); + textWriter.Write('['); + +#if NETCOREAPP + Span span = stackalloc char[10]; + if (eventId.TryFormat(span, out int charsWritten)) + { + textWriter.Write(span.Slice(0, charsWritten)); + } + else +#endif + { + textWriter.Write(eventId.ToString()); + } + + textWriter.Write(']'); + if (!singleLine) + { + textWriter.Write(Environment.NewLine); + } + + // scope information + WriteScopeInformation(textWriter, scopeProvider, singleLine); + WriteMessage(textWriter, message, singleLine); + + // Example: + // System.InvalidOperationException + // at Namespace.Class.Function() in File:line X + if (exception is not null) + { + // exception message + WriteMessage(textWriter, exception.ToString(), singleLine); + } + + if (singleLine) + { + textWriter.Write(Environment.NewLine); + } + } + + private static void WriteMessage(TextWriter textWriter, string message, bool singleLine) + { + if (!string.IsNullOrEmpty(message)) + { + if (singleLine) + { + textWriter.Write(' '); + WriteReplacing(textWriter, Environment.NewLine, " ", message); + } + else + { + textWriter.Write(MessagePadding); + WriteReplacing(textWriter, Environment.NewLine, NewLineWithMessagePadding, message); + textWriter.Write(Environment.NewLine); + } + } + + static void WriteReplacing(TextWriter writer, string oldValue, string newValue, string message) + { + string newMessage = message.Replace(oldValue, newValue); + writer.Write(newMessage); + } + } + + private void WriteScopeInformation(TextWriter textWriter, IExternalScopeProvider? scopeProvider, bool singleLine) + { + if (_options.IncludeScopes && scopeProvider is not null) + { + bool paddingNeeded = !singleLine; + scopeProvider.ForEachScope((scope, state) => + { + if (paddingNeeded) + { + paddingNeeded = false; + state.Write(MessagePadding); + state.Write("=> "); + } + else + { + state.Write(" => "); + } + + state.Write(scope); + }, + textWriter); + + if (!paddingNeeded && !singleLine) + { + textWriter.Write(Environment.NewLine); + } + } + } + + private static string GetLogLevelString(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => "trce", + LogLevel.Debug => "dbug", + LogLevel.Information => "info", + LogLevel.Warning => "warn", + LogLevel.Error => "fail", + LogLevel.Critical => "crit", + _ => throw new ArgumentOutOfRangeException(nameof(logLevel)) + }; + } + + private DateTimeOffset GetCurrentDateTime() + { + return _options.UseUtcTimestamp ? DateTimeOffset.UtcNow : DateTimeOffset.Now; + } + } + + private class ConsoleCaptureOptions : ConsoleFormatterOptions + { + /// + /// When , the entire message gets logged in a single line. + /// + public bool SingleLine { get; set; } + } +} diff --git a/test/MockHttp.Server.Tests/Fixtures/LoggerFactoryFixture.cs b/test/MockHttp.Server.Tests/Fixtures/LoggerFactoryFixture.cs new file mode 100644 index 00000000..5b09f61f --- /dev/null +++ b/test/MockHttp.Server.Tests/Fixtures/LoggerFactoryFixture.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MockHttp.Fixtures; + +public abstract class LoggerFactoryFixture : IAsyncLifetime, IAsyncDisposable +{ + private readonly ServiceProvider _services; + + protected LoggerFactoryFixture(Action? configure = null) + { + _services = new ServiceCollection() + .AddLogging(builder => + { + builder.SetMinimumLevel(LogLevel.Trace); + + builder + .AddDebug() +#if NET6_0_OR_GREATER + .AddSimpleConsole(opts => opts.IncludeScopes = true) +#endif + ; + + configure?.Invoke(builder); + }) + .BuildServiceProvider(); + + Factory = _services.GetRequiredService(); + } + + public ILoggerFactory Factory { get; } + + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore(); + GC.SuppressFinalize(this); + } + + Task IAsyncLifetime.InitializeAsync() + { + return Task.CompletedTask; + } + + Task IAsyncLifetime.DisposeAsync() + { + return DisposeAsync().AsTask(); + } + + protected virtual async ValueTask DisposeAsyncCore() + { + await _services.DisposeAsync(); + } +} diff --git a/test/MockHttp.Server.Tests/Fixtures/MockHttpServerFixture.cs b/test/MockHttp.Server.Tests/Fixtures/MockHttpServerFixture.cs index 798e8ffe..5c864da3 100644 --- a/test/MockHttp.Server.Tests/Fixtures/MockHttpServerFixture.cs +++ b/test/MockHttp.Server.Tests/Fixtures/MockHttpServerFixture.cs @@ -1,18 +1,13 @@ using System.Net.NetworkInformation; using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Logging; -using Serilog; -using Serilog.Core; -using Serilog.Events; -using Serilog.Extensions.Logging; -using Serilog.Sinks.TestCorrelator; using Xunit.Abstractions; namespace MockHttp.Fixtures; public class MockHttpServerFixture : IDisposable, IAsyncLifetime { - private ITestCorrelatorContext? _testCorrelatorContext; + private readonly CapturingLoggerFactoryFixture _loggerFactoryFixture; + private CapturingLoggerFactoryFixture.LogContext? _loggerCtx; public MockHttpServerFixture() : this("http") @@ -21,17 +16,11 @@ public MockHttpServerFixture() protected MockHttpServerFixture(string scheme) { - Logger logger = new LoggerConfiguration() - .MinimumLevel.Verbose() - .Enrich.FromLogContext() - .WriteTo.TestCorrelator() - .WriteTo.Debug() - .CreateLogger(); - LoggerFactory = new SerilogLoggerFactory(logger); + _loggerFactoryFixture = new CapturingLoggerFactoryFixture(); Handler = new MockHttpHandler(); Server = new MockHttpServer( Handler, - LoggerFactory, + _loggerFactoryFixture.Factory, new Uri( SupportsIpv6() ? $"{scheme}://[::1]:0" @@ -42,33 +31,33 @@ protected MockHttpServerFixture(string scheme) .Configure(builder => builder .Use((_, next) => { - _testCorrelatorContext ??= TestCorrelator.CreateContext(); + _loggerCtx ??= CapturingLoggerFactoryFixture.CreateContext(); return next(); }) ); } - public ILoggerFactory LoggerFactory { get; } - public MockHttpHandler Handler { get; } public MockHttpServer Server { get; } - public void Dispose() + public Task InitializeAsync() { - Server.Dispose(); - Handler.Dispose(); - GC.SuppressFinalize(this); + return Server.StartAsync(); } - public Task InitializeAsync() + public async Task DisposeAsync() { - return Server.StartAsync(); + await _loggerFactoryFixture.DisposeAsync(); + await Server.DisposeAsync(); } - public Task DisposeAsync() + public void Dispose() { - return Server.DisposeAsync().AsTask(); + _loggerCtx?.Dispose(); + Server.Dispose(); + Handler.Dispose(); + GC.SuppressFinalize(this); } private static bool SupportsIpv6() @@ -80,21 +69,20 @@ private static bool SupportsIpv6() // ReSharper disable once MemberCanBeMadeStatic.Global public void LogServerTrace(ITestOutputHelper testOutputHelper) { - if (_testCorrelatorContext is null) + if (_loggerCtx is null) { return; } - var logEvents = TestCorrelator.GetLogEventsFromContextGuid(_testCorrelatorContext.Guid).ToList(); - foreach (LogEvent logEvent in logEvents) + foreach (string msg in _loggerCtx.Events) { - testOutputHelper.WriteLine(logEvent.RenderMessage()); + testOutputHelper.WriteLine(msg); } } public void Reset() { Handler.Reset(); - _testCorrelatorContext = null; + _loggerCtx = null; } } diff --git a/test/MockHttp.Server.Tests/MockHttp.Server.Tests.csproj b/test/MockHttp.Server.Tests/MockHttp.Server.Tests.csproj index f639c14f..0c2e5b68 100644 --- a/test/MockHttp.Server.Tests/MockHttp.Server.Tests.csproj +++ b/test/MockHttp.Server.Tests/MockHttp.Server.Tests.csproj @@ -6,8 +6,6 @@ true MockHttp - 8.0.0 - 3.1.0 @@ -15,10 +13,7 @@ - - - - + diff --git a/test/MockHttp.Testing/MockHttp.Testing.csproj b/test/MockHttp.Testing/MockHttp.Testing.csproj index ef75910a..e63981d9 100644 --- a/test/MockHttp.Testing/MockHttp.Testing.csproj +++ b/test/MockHttp.Testing/MockHttp.Testing.csproj @@ -10,7 +10,7 @@ - +