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 @@
+
- allruntime; 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