diff --git a/.gitignore b/.gitignore
index 740dafc6d7..01a7753946 100644
--- a/.gitignore
+++ b/.gitignore
@@ -438,4 +438,4 @@ doc/plans/
*.nettrace
# Git worktrees
-.worktrees/.worktrees/
+.worktrees/
diff --git a/TUnit.Core/Logging/DefaultLogger.cs b/TUnit.Core/Logging/DefaultLogger.cs
index 9479873ec2..17a8b9d78f 100644
--- a/TUnit.Core/Logging/DefaultLogger.cs
+++ b/TUnit.Core/Logging/DefaultLogger.cs
@@ -124,16 +124,13 @@ protected virtual string GenerateMessage(string message, Exception? exception, L
/// True if this is an error-level message.
protected virtual void WriteToOutput(string message, bool isError)
{
- if (isError)
- {
- context.ErrorOutputWriter.WriteLine(message);
- GlobalContext.Current.OriginalConsoleError.WriteLine(message);
- }
- else
- {
- context.OutputWriter.WriteLine(message);
- GlobalContext.Current.OriginalConsoleOut.WriteLine(message);
- }
+ var level = isError ? LogLevel.Error : LogLevel.Information;
+
+ // Route to registered log sinks - they handle output destinations:
+ // - TestOutputSink: accumulates to context for test results
+ // - ConsoleOutputSink: writes to console (if --output Detailed)
+ // - RealTimeOutputSink: streams to IDEs
+ LogSinkRouter.RouteToSinks(level, message, null, context);
}
///
@@ -145,15 +142,12 @@ protected virtual void WriteToOutput(string message, bool isError)
/// A task representing the async operation.
protected virtual async ValueTask WriteToOutputAsync(string message, bool isError)
{
- if (isError)
- {
- await context.ErrorOutputWriter.WriteLineAsync(message);
- await GlobalContext.Current.OriginalConsoleError.WriteLineAsync(message);
- }
- else
- {
- await context.OutputWriter.WriteLineAsync(message);
- await GlobalContext.Current.OriginalConsoleOut.WriteLineAsync(message);
- }
+ var level = isError ? LogLevel.Error : LogLevel.Information;
+
+ // Route to registered log sinks - they handle output destinations:
+ // - TestOutputSink: accumulates to context for test results
+ // - ConsoleOutputSink: writes to console (if --output Detailed)
+ // - RealTimeOutputSink: streams to IDEs
+ await LogSinkRouter.RouteToSinksAsync(level, message, null, context);
}
}
diff --git a/TUnit.Core/Logging/ILogSink.cs b/TUnit.Core/Logging/ILogSink.cs
new file mode 100644
index 0000000000..8a0161689b
--- /dev/null
+++ b/TUnit.Core/Logging/ILogSink.cs
@@ -0,0 +1,108 @@
+namespace TUnit.Core.Logging;
+
+///
+/// Represents a destination for log messages. Implement this interface
+/// to create custom log sinks that receive output from tests.
+///
+///
+///
+/// Log sinks receive all output from:
+///
+/// - Console.WriteLine() calls during test execution
+/// - Console.Error.WriteLine() calls (with )
+/// - TUnit logger output via TestContext.Current.GetDefaultLogger()
+///
+///
+///
+/// Register your sink in a [Before(Assembly)] hook or before tests run using
+/// .
+///
+///
+///
+///
+/// // Example: File logging sink
+/// public class FileLogSink : ILogSink, IAsyncDisposable
+/// {
+/// private readonly StreamWriter _writer;
+///
+/// public FileLogSink(string path)
+/// {
+/// _writer = new StreamWriter(path, append: true);
+/// }
+///
+/// public bool IsEnabled(LogLevel level) => level >= LogLevel.Information;
+///
+/// public void Log(LogLevel level, string message, Exception? exception, Context? context)
+/// {
+/// var testName = context is TestContext tc ? tc.TestDetails.TestName : "Unknown";
+/// _writer.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{level}] [{testName}] {message}");
+/// if (exception != null)
+/// _writer.WriteLine(exception.ToString());
+/// }
+///
+/// public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+/// {
+/// Log(level, message, exception, context);
+/// return ValueTask.CompletedTask;
+/// }
+///
+/// public async ValueTask DisposeAsync()
+/// {
+/// await _writer.FlushAsync();
+/// await _writer.DisposeAsync();
+/// }
+/// }
+///
+/// // Register in assembly hook:
+/// [Before(Assembly)]
+/// public static void SetupLogging()
+/// {
+/// TUnitLoggerFactory.AddSink(new FileLogSink("test-output.log"));
+/// }
+///
+///
+public interface ILogSink
+{
+ ///
+ /// Determines if this sink should receive messages at the specified level.
+ /// Return false to skip processing for performance.
+ ///
+ /// The log level to check.
+ /// true if messages at this level should be logged; otherwise false.
+ bool IsEnabled(LogLevel level);
+
+ ///
+ /// Synchronously logs a message to this sink.
+ ///
+ /// The log level (Information, Warning, Error, etc.).
+ /// The formatted message to log.
+ /// Optional exception associated with this log entry.
+ ///
+ /// The current execution context, which may be:
+ ///
+ /// - - during test execution
+ /// - - during class hooks
+ /// - - during assembly hooks
+ /// - null - if outside test execution
+ ///
+ ///
+ void Log(LogLevel level, string message, Exception? exception, Context? context);
+
+ ///
+ /// Asynchronously logs a message to this sink.
+ ///
+ /// The log level (Information, Warning, Error, etc.).
+ /// The formatted message to log.
+ /// Optional exception associated with this log entry.
+ ///
+ /// The current execution context, which may be:
+ ///
+ /// - - during test execution
+ /// - - during class hooks
+ /// - - during assembly hooks
+ /// - null - if outside test execution
+ ///
+ ///
+ /// A representing the asynchronous operation.
+ ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context);
+}
diff --git a/TUnit.Core/Logging/LogSinkRouter.cs b/TUnit.Core/Logging/LogSinkRouter.cs
new file mode 100644
index 0000000000..526c623c0b
--- /dev/null
+++ b/TUnit.Core/Logging/LogSinkRouter.cs
@@ -0,0 +1,63 @@
+namespace TUnit.Core.Logging;
+
+///
+/// Internal helper for routing log messages to all registered sinks.
+///
+internal static class LogSinkRouter
+{
+ public static void RouteToSinks(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ var sinks = TUnitLoggerFactory.GetSinks();
+ if (sinks.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var sink in sinks)
+ {
+ if (!sink.IsEnabled(level))
+ {
+ continue;
+ }
+
+ try
+ {
+ sink.Log(level, message, exception, context);
+ }
+ catch (Exception ex)
+ {
+ // Write to original console to avoid recursion
+ GlobalContext.Current.OriginalConsoleError.WriteLine(
+ $"[TUnit] Log sink {sink.GetType().Name} failed: {ex.Message}");
+ }
+ }
+ }
+
+ public static async ValueTask RouteToSinksAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ var sinks = TUnitLoggerFactory.GetSinks();
+ if (sinks.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var sink in sinks)
+ {
+ if (!sink.IsEnabled(level))
+ {
+ continue;
+ }
+
+ try
+ {
+ await sink.LogAsync(level, message, exception, context).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // Write to original console to avoid recursion
+ await GlobalContext.Current.OriginalConsoleError.WriteLineAsync(
+ $"[TUnit] Log sink {sink.GetType().Name} failed: {ex.Message}").ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/TUnit.Core/Logging/TUnitLoggerFactory.cs b/TUnit.Core/Logging/TUnitLoggerFactory.cs
new file mode 100644
index 0000000000..1eaab9adda
--- /dev/null
+++ b/TUnit.Core/Logging/TUnitLoggerFactory.cs
@@ -0,0 +1,174 @@
+namespace TUnit.Core.Logging;
+
+///
+/// Factory for configuring and managing log sinks in TUnit.
+/// Use this class to register custom sinks that receive test output.
+///
+///
+///
+/// TUnit uses a sink-based logging architecture where all test output
+/// (Console.WriteLine, logger calls, etc.) is routed through registered sinks.
+/// Each sink can handle the output differently - write to files, stream to IDEs,
+/// send to external services, etc.
+///
+///
+/// Built-in sinks (registered automatically):
+///
+/// - TestOutputSink - Captures output for test results
+/// - ConsoleOutputSink - Real-time console output (when --output Detailed)
+/// - RealTimeOutputSink - Streams to IDE test explorers
+///
+///
+///
+/// Register custom sinks in a [Before(Assembly)] hook:
+///
+///
+///
+///
+/// // Register a custom sink in an assembly hook
+/// public class TestSetup
+/// {
+/// [Before(Assembly)]
+/// public static void SetupLogging()
+/// {
+/// // Register by instance (for sinks needing configuration)
+/// TUnitLoggerFactory.AddSink(new FileLogSink("test-output.log"));
+///
+/// // Or register by type (for simple sinks)
+/// TUnitLoggerFactory.AddSink<DebugLogSink>();
+/// }
+/// }
+///
+/// // Simple sink that writes to Debug output
+/// public class DebugLogSink : ILogSink
+/// {
+/// public bool IsEnabled(LogLevel level) => true;
+///
+/// public void Log(LogLevel level, string message, Exception? exception, Context? context)
+/// {
+/// System.Diagnostics.Debug.WriteLine($"[{level}] {message}");
+/// }
+///
+/// public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+/// {
+/// Log(level, message, exception, context);
+/// return ValueTask.CompletedTask;
+/// }
+/// }
+///
+///
+public static class TUnitLoggerFactory
+{
+ private static readonly List _sinks = [];
+ private static readonly Lock _lock = new();
+
+ ///
+ /// Registers a log sink instance to receive log messages from all tests.
+ ///
+ /// The sink instance to register.
+ ///
+ ///
+ /// Call this method in a [Before(Assembly)] hook to ensure the sink
+ /// is registered before any tests run.
+ ///
+ ///
+ /// If your sink implements or ,
+ /// it will be automatically disposed when the test session ends.
+ ///
+ ///
+ ///
+ ///
+ /// [Before(Assembly)]
+ /// public static void SetupLogging()
+ /// {
+ /// TUnitLoggerFactory.AddSink(new FileLogSink("test-output.log"));
+ /// }
+ ///
+ ///
+ public static void AddSink(ILogSink sink)
+ {
+ lock (_lock)
+ {
+ _sinks.Add(sink);
+ }
+ }
+
+ ///
+ /// Registers a log sink by type. TUnit will create a new instance using the parameterless constructor.
+ ///
+ ///
+ /// The sink type to register. Must implement and have a parameterless constructor.
+ ///
+ ///
+ /// Use this overload for simple sinks that don't require constructor parameters.
+ /// For sinks that need configuration, use instead.
+ ///
+ ///
+ ///
+ /// [Before(Assembly)]
+ /// public static void SetupLogging()
+ /// {
+ /// TUnitLoggerFactory.AddSink<DebugLogSink>();
+ /// }
+ ///
+ ///
+ public static void AddSink() where TSink : ILogSink, new()
+ {
+ AddSink(new TSink());
+ }
+
+ ///
+ /// Gets all registered sinks. For internal use.
+ ///
+ internal static IReadOnlyList GetSinks()
+ {
+ lock (_lock)
+ {
+ return _sinks.ToArray();
+ }
+ }
+
+ ///
+ /// Disposes all sinks that implement IAsyncDisposable or IDisposable.
+ /// Called at end of test session.
+ ///
+ internal static async ValueTask DisposeAllAsync()
+ {
+ ILogSink[] sinksToDispose;
+ lock (_lock)
+ {
+ sinksToDispose = _sinks.ToArray();
+ _sinks.Clear();
+ }
+
+ foreach (var sink in sinksToDispose)
+ {
+ try
+ {
+ if (sink is IAsyncDisposable asyncDisposable)
+ {
+ await asyncDisposable.DisposeAsync().ConfigureAwait(false);
+ }
+ else if (sink is IDisposable disposable)
+ {
+ disposable.Dispose();
+ }
+ }
+ catch
+ {
+ // Swallow disposal errors
+ }
+ }
+ }
+
+ ///
+ /// Clears all registered sinks. For testing purposes.
+ ///
+ internal static void Clear()
+ {
+ lock (_lock)
+ {
+ _sinks.Clear();
+ }
+ }
+}
diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs
index bff64d228a..66e3822a51 100644
--- a/TUnit.Engine/Framework/TUnitServiceProvider.cs
+++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs
@@ -11,6 +11,7 @@
using TUnit.Core;
using TUnit.Core.Helpers;
using TUnit.Core.Interfaces;
+using TUnit.Core.Logging;
using TUnit.Core.Tracking;
using TUnit.Engine.Building;
using TUnit.Engine.Building.Collectors;
@@ -25,6 +26,8 @@
using TUnit.Engine.Services;
using TUnit.Engine.Services.TestExecution;
+#pragma warning disable TPEXP // Experimental API - GetClientInfo
+
namespace TUnit.Engine.Framework;
internal class TUnitServiceProvider : IServiceProvider, IAsyncDisposable
@@ -136,6 +139,19 @@ public TUnitServiceProvider(IExtension extension,
frameworkServiceProvider,
context));
+ // Register log sinks based on output mode
+
+ // TestOutputSink: Always registered - accumulates to Context.OutputWriter/ErrorOutputWriter for test results
+ TUnitLoggerFactory.AddSink(new TestOutputSink());
+
+ // ConsoleOutputSink: For --output Detailed mode - real-time console output
+ if (VerbosityService.IsDetailedOutput)
+ {
+ TUnitLoggerFactory.AddSink(new ConsoleOutputSink(
+ StandardOutConsoleInterceptor.DefaultOut,
+ StandardErrorConsoleInterceptor.DefaultError));
+ }
+
CancellationToken = Register(new EngineCancellationToken());
EventReceiverOrchestrator = Register(new EventReceiverOrchestrator(Logger));
@@ -269,8 +285,8 @@ public TUnitServiceProvider(IExtension extension,
private void InitializeConsoleInterceptors()
{
- var outInterceptor = new StandardOutConsoleInterceptor(VerbosityService);
- var errorInterceptor = new StandardErrorConsoleInterceptor(VerbosityService);
+ var outInterceptor = new StandardOutConsoleInterceptor();
+ var errorInterceptor = new StandardErrorConsoleInterceptor();
outInterceptor.Initialize();
errorInterceptor.Initialize();
@@ -307,6 +323,9 @@ public async ValueTask DisposeAsync()
_services.Clear();
+ // Dispose all log sinks (flushes any remaining logs)
+ await TUnitLoggerFactory.DisposeAllAsync().ConfigureAwait(false);
+
TestExtensions.ClearCaches();
}
}
diff --git a/TUnit.Engine/Logging/AsyncConsoleWriter.cs b/TUnit.Engine/Logging/AsyncConsoleWriter.cs
deleted file mode 100644
index cdeffb7408..0000000000
--- a/TUnit.Engine/Logging/AsyncConsoleWriter.cs
+++ /dev/null
@@ -1,395 +0,0 @@
-using System.Text;
-using System.Threading.Channels;
-
-namespace TUnit.Engine.Logging;
-
-///
-/// Lock-free asynchronous console writer that maintains message order
-/// while eliminating contention between parallel tests
-///
-internal sealed class AsyncConsoleWriter : TextWriter
-{
- private readonly TextWriter _target;
- private readonly Channel _writeChannel;
- private readonly Task _processorTask;
- private readonly CancellationTokenSource _shutdownCts = new();
- private volatile bool _disposed;
-
- // Command types for the queue
- private enum CommandType
- {
- Write,
- WriteLine,
- Flush
- }
-
- private readonly struct WriteCommand
- {
- public CommandType Type { get; }
- public string? Text { get; }
-
- public WriteCommand(CommandType type, string? text = null)
- {
- Type = type;
- Text = text;
- }
-
- public static WriteCommand Write(string text) => new(CommandType.Write, text);
- public static WriteCommand WriteLine(string text) => new(CommandType.WriteLine, text);
- public static WriteCommand FlushCommand => new(CommandType.Flush);
- }
-
- public AsyncConsoleWriter(TextWriter target)
- {
- _target = target ?? throw new ArgumentNullException(nameof(target));
-
- // Create an unbounded channel for maximum throughput
- // Order is guaranteed by the channel
- _writeChannel = Channel.CreateUnbounded(new UnboundedChannelOptions
- {
- SingleWriter = false, // Multiple threads can write
- SingleReader = true, // Single background task reads
- AllowSynchronousContinuations = false // Don't block writers
- });
-
- // Start the background processor
- _processorTask = Task.Run(ProcessWritesAsync);
- }
-
- public override Encoding Encoding => _target.Encoding;
-
- public override void Write(char value)
- {
- if (_disposed)
- {
- return;
- }
- Write(value.ToString());
- }
-
- public override void Write(string? value)
- {
- if (_disposed || string.IsNullOrEmpty(value))
- {
- return;
- }
-
- // Non-blocking write to channel
- if (!_writeChannel.Writer.TryWrite(WriteCommand.Write(value!)))
- {
- // Channel is closed, write directly
- try
- {
- _target.Write(value);
- }
- catch
- {
- // Ignore write errors
- }
- }
- }
-
- public override void Write(char[]? buffer)
- {
- if (_disposed || buffer == null)
- {
- return;
- }
- Write(new string(buffer));
- }
-
- public override void Write(char[] buffer, int index, int count)
- {
- if (_disposed || buffer == null || count <= 0)
- {
- return;
- }
- Write(new string(buffer, index, count));
- }
-
- public override void WriteLine()
- {
- if (_disposed)
- {
- return;
- }
- WriteLine(string.Empty);
- }
-
- public override void WriteLine(string? value)
- {
- if (_disposed)
- {
- return;
- }
-
- // Non-blocking write to channel
- if (!_writeChannel.Writer.TryWrite(WriteCommand.WriteLine(value ?? string.Empty)))
- {
- // Channel is closed, write directly
- try
- {
- _target.WriteLine(value);
- }
- catch
- {
- // Ignore write errors
- }
- }
- }
-
- public override void Flush()
- {
- if (_disposed)
- {
- return;
- }
-
- // Queue a flush command
- if (!_writeChannel.Writer.TryWrite(WriteCommand.FlushCommand))
- {
- // Channel is closed, flush directly
- try
- {
- _target.Flush();
- }
- catch
- {
- // Ignore flush errors
- }
- }
- }
-
- public override async Task FlushAsync()
- {
- if (_disposed)
- {
- return;
- }
-
- // Queue a flush and wait a bit for it to process
- _writeChannel.Writer.TryWrite(WriteCommand.FlushCommand);
- await Task.Delay(10); // Small delay to allow flush to process
- }
-
- ///
- /// Background task that processes all writes in order
- /// Optimized to drain the channel in batches to reduce async overhead
- ///
- private async Task ProcessWritesAsync()
- {
- var buffer = new StringBuilder(4096);
- var lastFlush = DateTime.UtcNow;
- const int flushIntervalMs = 50;
-
- try
- {
- // Drain-loop pattern: reduces async state machine overhead by processing all available items in one go
- while (await _writeChannel.Reader.WaitToReadAsync(_shutdownCts.Token).ConfigureAwait(false))
- {
- // Drain all immediately available items from the channel
- while (_writeChannel.Reader.TryRead(out var command))
- {
- switch (command.Type)
- {
- case CommandType.Write:
- buffer.Append(command.Text);
- break;
-
- case CommandType.WriteLine:
- buffer.AppendLine(command.Text);
- break;
-
- case CommandType.Flush:
- FlushBuffer(buffer);
- lastFlush = DateTime.UtcNow;
- continue;
- }
-
- // Check if we should flush mid-drain (for large buffers)
- if (buffer.Length > 4096)
- {
- FlushBuffer(buffer);
- lastFlush = DateTime.UtcNow;
- }
- }
-
- // Flush after draining batch if time-based threshold met
- if (buffer.Length > 0 && (DateTime.UtcNow - lastFlush).TotalMilliseconds > flushIntervalMs)
- {
- FlushBuffer(buffer);
- lastFlush = DateTime.UtcNow;
- }
- }
- }
- catch (OperationCanceledException)
- {
- // Normal shutdown
- }
- finally
- {
- // Final flush on shutdown
- if (buffer.Length > 0)
- {
- FlushBuffer(buffer);
- }
- }
- }
-
- private void FlushBuffer(StringBuilder buffer)
- {
- if (buffer.Length == 0)
- {
- return;
- }
-
- try
- {
- _target.Write(buffer.ToString());
- _target.Flush();
- }
- catch
- {
- // Ignore write errors
- }
- finally
- {
- buffer.Clear();
- }
- }
-
- protected override void Dispose(bool disposing)
- {
- if (_disposed)
- {
- return;
- }
- _disposed = true;
-
- if (disposing)
- {
- // Signal shutdown
- _writeChannel.Writer.TryComplete();
- _shutdownCts.Cancel();
-
- // Don't block on disposal - it can cause deadlocks
- // Just give the processor task a brief moment to complete naturally
- // The cancellation token will signal it to stop anyway
- try
- {
- // Use a very short non-blocking wait to see if it's already done
- _processorTask.Wait(1);
- }
- catch
- {
- // Ignore - the task might still be running, but we've signaled shutdown
- // and it will complete on its own
- }
-
- _shutdownCts.Dispose();
- }
-
- base.Dispose(disposing);
- }
-
- // Formatted write methods to match BufferedTextWriter interface
- public void WriteFormatted(string format, object? arg0)
- {
- if (_disposed)
- {
- return;
- }
- Write(string.Format(format, arg0));
- }
-
- public void WriteFormatted(string format, object? arg0, object? arg1)
- {
- if (_disposed)
- {
- return;
- }
- Write(string.Format(format, arg0, arg1));
- }
-
- public void WriteFormatted(string format, object? arg0, object? arg1, object? arg2)
- {
- if (_disposed)
- {
- return;
- }
- Write(string.Format(format, arg0, arg1, arg2));
- }
-
- public void WriteFormatted(string format, params object?[] args)
- {
- if (_disposed)
- {
- return;
- }
- Write(string.Format(format, args));
- }
-
- public void WriteLineFormatted(string format, object? arg0)
- {
- if (_disposed)
- {
- return;
- }
- WriteLine(string.Format(format, arg0));
- }
-
- public void WriteLineFormatted(string format, object? arg0, object? arg1)
- {
- if (_disposed)
- {
- return;
- }
- WriteLine(string.Format(format, arg0, arg1));
- }
-
- public void WriteLineFormatted(string format, object? arg0, object? arg1, object? arg2)
- {
- if (_disposed)
- {
- return;
- }
- WriteLine(string.Format(format, arg0, arg1, arg2));
- }
-
- public void WriteLineFormatted(string format, params object?[] args)
- {
- if (_disposed)
- {
- return;
- }
- WriteLine(string.Format(format, args));
- }
-
-#if NET
- public override async ValueTask DisposeAsync()
- {
- if (_disposed)
- {
- return;
- }
- _disposed = true;
-
- // Signal shutdown
- _writeChannel.Writer.TryComplete();
- _shutdownCts.Cancel();
-
- // Wait for processor to finish
- try
- {
- await _processorTask.ConfigureAwait(false);
- }
- catch
- {
- // Ignore
- }
-
- _shutdownCts.Dispose();
- await base.DisposeAsync().ConfigureAwait(false);
- }
-#endif
-}
\ No newline at end of file
diff --git a/TUnit.Engine/Logging/BufferedTextWriter.cs b/TUnit.Engine/Logging/BufferedTextWriter.cs
deleted file mode 100644
index 230c3d9f7f..0000000000
--- a/TUnit.Engine/Logging/BufferedTextWriter.cs
+++ /dev/null
@@ -1,510 +0,0 @@
-using System.Collections.Concurrent;
-using System.Text;
-
-#pragma warning disable CS8765 // Nullability of type of parameter doesn't match overridden member
-
-namespace TUnit.Engine.Logging;
-
-///
-/// A thread-safe buffered text writer that reduces allocation overhead
-/// Uses per-thread buffers to minimize lock contention
-///
-internal sealed class BufferedTextWriter : TextWriter, IDisposable
-{
- private readonly TextWriter _target;
- private readonly ReaderWriterLockSlim _lock = new();
- private readonly int _bufferSize;
- private readonly ThreadLocal _threadLocalBuffer;
- private readonly ConcurrentQueue _flushQueue = new();
- private volatile bool _disposed;
- private readonly Timer _flushTimer;
-
- public BufferedTextWriter(TextWriter target, int bufferSize = 4096)
- {
- _target = target ?? throw new ArgumentNullException(nameof(target));
- _bufferSize = bufferSize;
- _threadLocalBuffer = new ThreadLocal(() => new StringBuilder(bufferSize));
-
- // Auto-flush every 100ms to prevent data loss
- _flushTimer = new Timer(AutoFlush, null, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(100));
- }
-
- public override Encoding Encoding => _target.Encoding;
-
- public override IFormatProvider FormatProvider => _target.FormatProvider;
-
- public override string NewLine
- {
- get => _target.NewLine;
- set
- {
- _lock.EnterWriteLock();
- try
- {
- _target.NewLine = value ?? Environment.NewLine;
- }
- finally
- {
- _lock.ExitWriteLock();
- }
- }
- }
-
- public override void Write(char value)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.Append(value);
- CheckFlush(buffer);
- }
-
- public override void Write(string? value)
- {
- if (string.IsNullOrEmpty(value) || _disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.Append(value);
- CheckFlush(buffer);
- }
-
- public override void Write(char[] buffer, int index, int count)
- {
- if (buffer == null || count <= 0 || _disposed)
- {
- return;
- }
-
- var localBuffer = _threadLocalBuffer.Value;
- if (localBuffer == null)
- {
- return;
- }
-
- localBuffer.Append(buffer, index, count);
- CheckFlush(localBuffer);
- }
-
- public override void WriteLine()
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendLine();
- FlushBuffer(buffer);
- }
-
- public override void WriteLine(string? value)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendLine(value);
- FlushBuffer(buffer);
- }
-
- // Optimized Write methods to avoid boxing and tuple allocations
- public void WriteFormatted(string format, object? arg0)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendFormat(format, arg0);
- CheckFlush(buffer);
- }
-
- public void WriteFormatted(string format, object? arg0, object? arg1)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendFormat(format, arg0, arg1);
- CheckFlush(buffer);
- }
-
- public void WriteFormatted(string format, object? arg0, object? arg1, object? arg2)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendFormat(format, arg0, arg1, arg2);
- CheckFlush(buffer);
- }
-
- public void WriteFormatted(string format, params object?[] args)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendFormat(format, args);
- CheckFlush(buffer);
- }
-
- public void WriteLineFormatted(string format, object? arg0)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendFormat(format, arg0);
- buffer.AppendLine();
- FlushBuffer(buffer);
- }
-
- public void WriteLineFormatted(string format, object? arg0, object? arg1)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendFormat(format, arg0, arg1);
- buffer.AppendLine();
- FlushBuffer(buffer);
- }
-
- public void WriteLineFormatted(string format, object? arg0, object? arg1, object? arg2)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendFormat(format, arg0, arg1, arg2);
- buffer.AppendLine();
- FlushBuffer(buffer);
- }
-
- public void WriteLineFormatted(string format, params object?[] args)
- {
- if (_disposed)
- {
- return;
- }
-
- var buffer = _threadLocalBuffer.Value;
- if (buffer == null)
- {
- return;
- }
-
- buffer.AppendFormat(format, args);
- buffer.AppendLine();
- FlushBuffer(buffer);
- }
-
- public override void Flush()
- {
- // Flush all thread-local buffers
- FlushAllThreadBuffers();
-
- // Collect content to write without holding the lock
- var contentToWrite = new List();
-
- _lock.EnterWriteLock();
- try
- {
- // Dequeue all content while holding the lock
- while (_flushQueue.TryDequeue(out var content))
- {
- contentToWrite.Add(content);
- }
- }
- finally
- {
- _lock.ExitWriteLock();
- }
-
- // Write content and flush outside the lock to avoid deadlock
- foreach (var content in contentToWrite)
- {
- _target.Write(content);
- }
- _target.Flush();
- }
-
- public override async Task FlushAsync()
- {
- // Flush all thread-local buffers
- FlushAllThreadBuffers();
-
- var contentToWrite = new List();
-
- _lock.EnterWriteLock();
- try
- {
- // Get all queued content
- while (_flushQueue.TryDequeue(out var content))
- {
- contentToWrite.Add(content);
- }
- }
- finally
- {
- _lock.ExitWriteLock();
- }
-
- // Write all content asynchronously
- foreach (var content in contentToWrite)
- {
- await _target.WriteAsync(content);
- }
-
- await _target.FlushAsync();
- }
-
- private void CheckFlush(StringBuilder buffer)
- {
- // Flush if buffer is getting large
- if (buffer.Length >= _bufferSize)
- {
- FlushBuffer(buffer);
- }
- }
-
- private void FlushBuffer(StringBuilder buffer)
- {
- if (buffer.Length == 0)
- {
- return;
- }
-
- var content = buffer.ToString();
- buffer.Clear();
-
- // Queue content for batch writing
- _flushQueue.Enqueue(content);
-
- // Process queue if it's getting large
- if (_flushQueue.Count > 10)
- {
- // Collect content to write without holding the lock
- var contentToWrite = new List();
-
- _lock.EnterWriteLock();
- try
- {
- // Dequeue content while holding the lock
- while (_flushQueue.TryDequeue(out var queuedContent) && contentToWrite.Count < 20)
- {
- contentToWrite.Add(queuedContent);
- }
- }
- finally
- {
- _lock.ExitWriteLock();
- }
-
- // Write content outside the lock to avoid deadlock
- foreach (var contentItem in contentToWrite)
- {
- _target.Write(contentItem);
- }
- }
- }
-
- private void FlushAllThreadBuffers()
- {
- // This forces all thread-local buffers to be flushed
- // by accessing them from the current thread context
- var currentBuffer = _threadLocalBuffer.Value;
- if (currentBuffer?.Length > 0)
- {
- FlushBuffer(currentBuffer);
- }
- }
-
- private void ProcessFlushQueue()
- {
- // Process all queued content
- while (_flushQueue.TryDequeue(out var content))
- {
- _target.Write(content);
- }
- }
-
- private void AutoFlush(object? state)
- {
- if (_disposed)
- {
- return;
- }
-
- try
- {
- FlushAllThreadBuffers();
-
- // Collect content to write without holding the lock
- var contentToWrite = new List();
-
- _lock.EnterWriteLock();
- try
- {
- // Dequeue all content while holding the lock
- while (_flushQueue.TryDequeue(out var content))
- {
- contentToWrite.Add(content);
- }
- }
- finally
- {
- _lock.ExitWriteLock();
- }
-
- // Write content outside the lock to avoid deadlock
- foreach (var content in contentToWrite)
- {
- _target.Write(content);
- }
- }
- catch
- {
- // Ignore errors in auto-flush to prevent crashes
- }
- }
-
- protected override void Dispose(bool disposing)
- {
- if (!_disposed && disposing)
- {
- _flushTimer?.Dispose();
- FlushAllThreadBuffers();
-
- // Collect content to write without holding the lock
- var contentToWrite = new List();
-
- _lock.EnterWriteLock();
- try
- {
- // Dequeue all content while holding the lock
- while (_flushQueue.TryDequeue(out var content))
- {
- contentToWrite.Add(content);
- }
- _disposed = true;
- }
- finally
- {
- _lock.ExitWriteLock();
- }
-
- // Write content outside the lock
- foreach (var content in contentToWrite)
- {
- _target.Write(content);
- }
-
- _threadLocalBuffer?.Dispose();
- _lock?.Dispose();
- }
- base.Dispose(disposing);
- }
-
-#if NET
- public override async ValueTask DisposeAsync()
- {
- if (!_disposed)
- {
- _flushTimer?.Dispose();
- await FlushAsync();
-
- _lock.EnterWriteLock();
- try
- {
- _disposed = true;
- }
- finally
- {
- _lock.ExitWriteLock();
- }
-
- _threadLocalBuffer?.Dispose();
- _lock?.Dispose();
- }
- await base.DisposeAsync().ConfigureAwait(false);
- }
-#endif
-}
\ No newline at end of file
diff --git a/TUnit.Engine/Logging/ConsoleOutputSink.cs b/TUnit.Engine/Logging/ConsoleOutputSink.cs
new file mode 100644
index 0000000000..ed05254333
--- /dev/null
+++ b/TUnit.Engine/Logging/ConsoleOutputSink.cs
@@ -0,0 +1,34 @@
+using TUnit.Core;
+using TUnit.Core.Logging;
+
+namespace TUnit.Engine.Logging;
+
+///
+/// A log sink that writes output to the actual console (stdout/stderr).
+/// Only registered when --output Detailed is specified.
+///
+internal sealed class ConsoleOutputSink : ILogSink
+{
+ private readonly TextWriter _stdout;
+ private readonly TextWriter _stderr;
+
+ public ConsoleOutputSink(TextWriter stdout, TextWriter stderr)
+ {
+ _stdout = stdout;
+ _stderr = stderr;
+ }
+
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ var writer = level >= LogLevel.Error ? _stderr : _stdout;
+ writer.WriteLine(message);
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Log(level, message, exception, context);
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
index 86239ace6f..3ba2bc37d2 100644
--- a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
+++ b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
@@ -1,596 +1,199 @@
using System.Text;
-using TUnit.Engine.Services;
+using TUnit.Core;
+using TUnit.Core.Logging;
#pragma warning disable CS8765 // Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes).
namespace TUnit.Engine.Logging;
///
-/// Optimized console interceptor that eliminates tuple allocations and uses buffered output
+/// Console interceptor that captures output and routes it to registered sinks.
+/// The interceptor itself doesn't write anywhere - it only routes to sinks:
+/// - TestOutputSink: accumulates to Context.OutputWriter/ErrorOutputWriter
+/// - ConsoleOutputSink: writes to actual console
+/// - RealTimeOutputSink: streams to IDEs
///
internal abstract class OptimizedConsoleInterceptor : TextWriter
{
- private readonly VerbosityService _verbosityService;
- private readonly BufferedTextWriter? _originalOutBuffer;
+ private readonly StringBuilder _lineBuffer = new();
- protected OptimizedConsoleInterceptor(VerbosityService verbosityService)
- {
- _verbosityService = verbosityService;
-
- var originalOut = GetOriginalOut();
-
- // Wrap outputs with buffered writers for better performance
- _originalOutBuffer = originalOut != null ? new BufferedTextWriter(originalOut, 2048) : null;
- }
-
- public override Encoding Encoding => RedirectedOut?.Encoding ?? _originalOutBuffer?.Encoding ?? Encoding.UTF8;
+ public override Encoding Encoding => Encoding.UTF8;
- protected abstract TextWriter? RedirectedOut { get; }
+ ///
+ /// Gets the log level to use when routing console output to sinks.
+ ///
+ protected abstract LogLevel SinkLogLevel { get; }
private protected abstract TextWriter GetOriginalOut();
private protected abstract void ResetDefault();
-#if NET
- public override ValueTask DisposeAsync()
+ ///
+ /// Routes the message to registered log sinks.
+ ///
+ private void RouteToSinks(string? message)
{
- ResetDefault();
- _originalOutBuffer?.Dispose();
- // Don't dispose RedirectedOut as it's not owned by us
- return ValueTask.CompletedTask;
- }
-#endif
-
- public override void Flush()
- {
- _originalOutBuffer?.Flush();
- RedirectedOut?.Flush();
- }
-
- public override async Task FlushAsync()
- {
- if (_originalOutBuffer != null)
+ if (message is not null && message.Length > 0)
{
- await _originalOutBuffer.FlushAsync();
+ LogSinkRouter.RouteToSinks(SinkLogLevel, message, null, Context.Current);
}
- if (RedirectedOut != null)
- {
- await RedirectedOut.FlushAsync();
- }
- }
-
- // Optimized Write methods - no tuple allocations
-
- public override void Write(bool value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(value.ToString());
- }
- RedirectedOut?.Write(value.ToString());
- }
-
- public override void Write(char value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(value);
- }
- RedirectedOut?.Write(value);
}
- public override void Write(char[]? buffer)
+ ///
+ /// Routes the message to registered log sinks asynchronously.
+ ///
+ private async ValueTask RouteToSinksAsync(string? message)
{
- if (buffer == null)
+ if (message is not null && message.Length > 0)
{
- return;
+ await LogSinkRouter.RouteToSinksAsync(SinkLogLevel, message, null, Context.Current).ConfigureAwait(false);
}
-
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(buffer);
- }
- RedirectedOut?.Write(buffer);
}
- public override void Write(decimal value)
- {
- var str = value.ToString();
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(str);
- }
- RedirectedOut?.Write(str);
- }
-
- public override void Write(double value)
- {
- var str = value.ToString();
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(str);
- }
- RedirectedOut?.Write(str);
- }
-
- public override void Write(int value)
+#if NET
+ public override ValueTask DisposeAsync()
{
- var str = value.ToString();
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(str);
- }
- RedirectedOut?.Write(str);
+ ResetDefault();
+ return ValueTask.CompletedTask;
}
+#endif
- public override void Write(long value)
+ public override void Flush()
{
- var str = value.ToString();
- if (!_verbosityService.HideTestOutput)
+ // Flush any buffered partial line
+ if (_lineBuffer.Length > 0)
{
- _originalOutBuffer?.Write(str);
+ RouteToSinks(_lineBuffer.ToString());
+ _lineBuffer.Clear();
}
- RedirectedOut?.Write(str);
}
- public override void Write(object? value)
+ public override async Task FlushAsync()
{
- var str = value?.ToString() ?? string.Empty;
- if (!_verbosityService.HideTestOutput)
+ if (_lineBuffer.Length > 0)
{
- _originalOutBuffer?.Write(str);
+ await RouteToSinksAsync(_lineBuffer.ToString()).ConfigureAwait(false);
+ _lineBuffer.Clear();
}
- RedirectedOut?.Write(str);
}
- public override void Write(float value)
+ // Write methods - buffer partial writes until we get a complete line
+ public override void Write(bool value) => Write(value.ToString());
+ public override void Write(char value) => BufferChar(value);
+ public override void Write(char[]? buffer)
{
- var str = value.ToString();
- if (!_verbosityService.HideTestOutput)
+ if (buffer != null)
{
- _originalOutBuffer?.Write(str);
+ BufferChars(buffer, 0, buffer.Length);
}
- RedirectedOut?.Write(str);
}
-
+ public override void Write(decimal value) => Write(value.ToString());
+ public override void Write(double value) => Write(value.ToString());
+ public override void Write(int value) => Write(value.ToString());
+ public override void Write(long value) => Write(value.ToString());
+ public override void Write(object? value) => Write(value?.ToString() ?? string.Empty);
+ public override void Write(float value) => Write(value.ToString());
public override void Write(string? value)
{
- if (value == null)
- {
- return;
- }
-
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(value);
- }
- RedirectedOut?.Write(value);
- }
-
- public override void Write(uint value)
- {
- var str = value.ToString();
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(str);
- }
- RedirectedOut?.Write(str);
- }
-
- public override void Write(ulong value)
- {
- var str = value.ToString();
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(str);
- }
- RedirectedOut?.Write(str);
+ if (value == null) return;
+ _lineBuffer.Append(value);
}
+ public override void Write(uint value) => Write(value.ToString());
+ public override void Write(ulong value) => Write(value.ToString());
+ public override void Write(char[] buffer, int index, int count) => BufferChars(buffer, index, count);
+ public override void Write(string format, object? arg0) => Write(string.Format(format, arg0));
+ public override void Write(string format, object? arg0, object? arg1) => Write(string.Format(format, arg0, arg1));
+ public override void Write(string format, object? arg0, object? arg1, object? arg2) => Write(string.Format(format, arg0, arg1, arg2));
+ public override void Write(string format, params object?[] arg) => Write(string.Format(format, arg));
- public override void Write(char[] buffer, int index, int count)
+ private void BufferChar(char value)
{
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(buffer, index, count);
- }
- RedirectedOut?.Write(buffer, index, count);
+ _lineBuffer.Append(value);
}
- // Optimized formatted Write methods - no tuple allocations
- public override void Write(string format, object? arg0)
+ private void BufferChars(char[] buffer, int index, int count)
{
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteFormatted(format, arg0);
- }
- RedirectedOut?.Write(format, arg0);
- }
-
- public override void Write(string format, object? arg0, object? arg1)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteFormatted(format, arg0, arg1);
- }
- RedirectedOut?.Write(format, arg0, arg1);
- }
-
- public override void Write(string format, object? arg0, object? arg1, object? arg2)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteFormatted(format, arg0, arg1, arg2);
- }
- RedirectedOut?.Write(format, arg0, arg1, arg2);
+ _lineBuffer.Append(buffer, index, count);
}
- public override void Write(string format, params object?[] arg)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteFormatted(format, arg);
- }
- RedirectedOut?.Write(format, arg);
- }
-
- // WriteLine methods
+ // WriteLine methods - flush buffer and route complete line to sinks
public override void WriteLine()
{
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine();
- }
- RedirectedOut?.WriteLine();
- }
-
- public override void WriteLine(bool value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(value.ToString());
- }
- RedirectedOut?.WriteLine(value.ToString());
+ var line = _lineBuffer.ToString();
+ _lineBuffer.Clear();
+ RouteToSinks(line);
}
- public override void WriteLine(char value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(value.ToString());
- }
- RedirectedOut?.WriteLine(value.ToString());
- }
-
- public override void WriteLine(char[]? buffer)
- {
- if (buffer == null)
- {
- return;
- }
-
- var str = new string(buffer);
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(str);
- }
- RedirectedOut?.WriteLine(str);
- }
-
- public override void WriteLine(char[] buffer, int index, int count)
- {
- var str = new string(buffer, index, count);
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(str);
- }
- RedirectedOut?.WriteLine(str);
- }
-
- public override void WriteLine(decimal value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(value.ToString());
- }
- RedirectedOut?.WriteLine(value.ToString());
- }
-
- public override void WriteLine(double value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(value.ToString());
- }
- RedirectedOut?.WriteLine(value.ToString());
- }
-
- public override void WriteLine(int value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(value.ToString());
- }
- RedirectedOut?.WriteLine(value.ToString());
- }
-
- public override void WriteLine(long value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(value.ToString());
- }
- RedirectedOut?.WriteLine(value.ToString());
- }
-
- public override void WriteLine(object? value)
- {
- var str = value?.ToString() ?? string.Empty;
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(str);
- }
- RedirectedOut?.WriteLine(str);
- }
-
- public override void WriteLine(float value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(value.ToString());
- }
- RedirectedOut?.WriteLine(value.ToString());
- }
+ public override void WriteLine(bool value) => WriteLine(value.ToString());
+ public override void WriteLine(char value) => WriteLine(value.ToString());
+ public override void WriteLine(char[]? buffer) => WriteLine(buffer != null ? new string(buffer) : string.Empty);
+ public override void WriteLine(char[] buffer, int index, int count) => WriteLine(new string(buffer, index, count));
+ public override void WriteLine(decimal value) => WriteLine(value.ToString());
+ public override void WriteLine(double value) => WriteLine(value.ToString());
+ public override void WriteLine(int value) => WriteLine(value.ToString());
+ public override void WriteLine(long value) => WriteLine(value.ToString());
+ public override void WriteLine(object? value) => WriteLine(value?.ToString() ?? string.Empty);
+ public override void WriteLine(float value) => WriteLine(value.ToString());
public override void WriteLine(string? value)
{
- if (!_verbosityService.HideTestOutput)
+ // Prepend any buffered content
+ if (_lineBuffer.Length > 0)
{
- _originalOutBuffer?.WriteLine(value);
+ _lineBuffer.Append(value);
+ value = _lineBuffer.ToString();
+ _lineBuffer.Clear();
}
- RedirectedOut?.WriteLine(value);
+ RouteToSinks(value);
}
- public override void WriteLine(uint value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(value.ToString());
- }
- RedirectedOut?.WriteLine(value.ToString());
- }
-
- public override void WriteLine(ulong value)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(value.ToString());
- }
- RedirectedOut?.WriteLine(value.ToString());
- }
-
- // Optimized formatted WriteLine methods - no tuple allocations
- public override void WriteLine(string format, object? arg0)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLineFormatted(format, arg0);
- }
- RedirectedOut?.WriteLine(format, arg0);
- }
-
- public override void WriteLine(string format, object? arg0, object? arg1)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLineFormatted(format, arg0, arg1);
- }
- RedirectedOut?.WriteLine(format, arg0, arg1);
- }
-
- public override void WriteLine(string format, object? arg0, object? arg1, object? arg2)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLineFormatted(format, arg0, arg1, arg2);
- }
- RedirectedOut?.WriteLine(format, arg0, arg1, arg2);
- }
-
- public override void WriteLine(string format, params object?[] arg)
- {
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLineFormatted(format, arg);
- }
- RedirectedOut?.WriteLine(format, arg);
- }
+ public override void WriteLine(uint value) => WriteLine(value.ToString());
+ public override void WriteLine(ulong value) => WriteLine(value.ToString());
+ public override void WriteLine(string format, object? arg0) => WriteLine(string.Format(format, arg0));
+ public override void WriteLine(string format, object? arg0, object? arg1) => WriteLine(string.Format(format, arg0, arg1));
+ public override void WriteLine(string format, object? arg0, object? arg1, object? arg2) => WriteLine(string.Format(format, arg0, arg1, arg2));
+ public override void WriteLine(string format, params object?[] arg) => WriteLine(string.Format(format, arg));
// Async methods
- public override async Task WriteLineAsync()
- {
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(Environment.NewLine);
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(Environment.NewLine);
- }
- }
-
- public override async Task WriteAsync(char value)
- {
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(value.ToString());
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(value.ToString());
- }
- }
-
- public override async Task WriteAsync(char[] buffer, int index, int count)
- {
- var str = new string(buffer, index, count);
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(str);
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(str);
- }
- }
-
- public override async Task WriteAsync(string? value)
- {
- if (value == null)
- {
- return;
- }
-
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(value);
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(value);
- }
- }
+ public override Task WriteLineAsync() => WriteLineAsync(string.Empty);
+ public override Task WriteAsync(char value) { Write(value); return Task.CompletedTask; }
+ public override Task WriteAsync(char[] buffer, int index, int count) { Write(buffer, index, count); return Task.CompletedTask; }
+ public override Task WriteAsync(string? value) { Write(value); return Task.CompletedTask; }
public override async Task WriteLineAsync(char value)
{
- var str = value + Environment.NewLine;
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(str);
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(str);
- }
+ await WriteLineAsync(value.ToString()).ConfigureAwait(false);
}
public override async Task WriteLineAsync(char[] buffer, int index, int count)
{
- var str = new string(buffer, index, count) + Environment.NewLine;
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(str);
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(str);
- }
+ await WriteLineAsync(new string(buffer, index, count)).ConfigureAwait(false);
}
public override async Task WriteLineAsync(string? value)
{
- var str = (value ?? string.Empty) + Environment.NewLine;
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(str);
- }
- if (RedirectedOut != null)
+ if (_lineBuffer.Length > 0)
{
- await RedirectedOut.WriteAsync(str);
+ _lineBuffer.Append(value);
+ value = _lineBuffer.ToString();
+ _lineBuffer.Clear();
}
+ await RouteToSinksAsync(value).ConfigureAwait(false);
}
#if NET
- public override void Write(ReadOnlySpan buffer)
- {
- var str = new string(buffer);
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(str);
- }
- RedirectedOut?.Write(str);
- }
-
- public override void Write(StringBuilder? value)
- {
- var str = value?.ToString() ?? string.Empty;
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.Write(str);
- }
- RedirectedOut?.Write(str);
- }
-
- public override async Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new())
- {
- var str = new string(buffer.Span);
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(str);
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(str);
- }
- }
-
- public override async Task WriteAsync(StringBuilder? value, CancellationToken cancellationToken = new())
- {
- var str = value?.ToString() ?? string.Empty;
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(str);
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(str);
- }
- }
-
- public override void WriteLine(ReadOnlySpan buffer)
- {
- var str = new string(buffer);
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(str);
- }
- RedirectedOut?.WriteLine(str);
- }
-
- public override void WriteLine(StringBuilder? value)
- {
- var str = value?.ToString() ?? string.Empty;
- if (!_verbosityService.HideTestOutput)
- {
- _originalOutBuffer?.WriteLine(str);
- }
- RedirectedOut?.WriteLine(str);
- }
-
- public override async Task WriteLineAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new())
- {
- var str = new string(buffer.Span) + Environment.NewLine;
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(str);
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(str);
- }
- }
-
- public override async Task WriteLineAsync(StringBuilder? value, CancellationToken cancellationToken = new())
- {
- var str = (value?.ToString() ?? string.Empty) + Environment.NewLine;
- if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
- {
- await _originalOutBuffer.WriteAsync(str);
- }
- if (RedirectedOut != null)
- {
- await RedirectedOut.WriteAsync(str);
- }
- }
+ public override void Write(ReadOnlySpan buffer) => Write(new string(buffer));
+ public override void Write(StringBuilder? value) => Write(value?.ToString() ?? string.Empty);
+ public override Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new())
+ => WriteAsync(new string(buffer.Span));
+ public override Task WriteAsync(StringBuilder? value, CancellationToken cancellationToken = new())
+ => WriteAsync(value?.ToString() ?? string.Empty);
+ public override void WriteLine(ReadOnlySpan buffer) => WriteLine(new string(buffer));
+ public override void WriteLine(StringBuilder? value) => WriteLine(value?.ToString() ?? string.Empty);
+ public override Task WriteLineAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new())
+ => WriteLineAsync(new string(buffer.Span));
+ public override Task WriteLineAsync(StringBuilder? value, CancellationToken cancellationToken = new())
+ => WriteLineAsync(value?.ToString() ?? string.Empty);
#endif
public override IFormatProvider FormatProvider => GetOriginalOut().FormatProvider;
@@ -598,21 +201,12 @@ public override void WriteLine(StringBuilder? value)
public override string NewLine
{
get => GetOriginalOut().NewLine;
- set
- {
- GetOriginalOut().NewLine = value;
- if (RedirectedOut != null)
- {
- RedirectedOut.NewLine = value;
- }
- }
+ set => GetOriginalOut().NewLine = value;
}
public override void Close()
{
Flush();
- _originalOutBuffer?.Dispose();
- // Don't dispose RedirectedOut as it's not owned by us
ResetDefault();
}
@@ -620,9 +214,8 @@ protected override void Dispose(bool disposing)
{
if (disposing)
{
- _originalOutBuffer?.Dispose();
- // Don't dispose RedirectedOut as it's not owned by us
+ Flush();
}
base.Dispose(disposing);
}
-}
\ No newline at end of file
+}
diff --git a/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs b/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
index 2d3080bcae..ba1e4fd0a6 100644
--- a/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
+++ b/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
@@ -1,7 +1,4 @@
-using TUnit.Core;
-using TUnit.Engine.Services;
-
-#pragma warning disable CS8765 // Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes).
+using TUnit.Core.Logging;
namespace TUnit.Engine.Logging;
@@ -11,19 +8,17 @@ internal class StandardErrorConsoleInterceptor : OptimizedConsoleInterceptor
public static TextWriter DefaultError { get; }
- protected override TextWriter RedirectedOut => Context.Current.ErrorOutputWriter;
+ protected override LogLevel SinkLogLevel => LogLevel.Error;
static StandardErrorConsoleInterceptor()
{
- // Get the raw stream without SyncTextWriter synchronization wrapper
- // BufferedTextWriter already provides thread safety, so we avoid double-locking
DefaultError = new StreamWriter(Console.OpenStandardError())
{
AutoFlush = true
};
}
- public StandardErrorConsoleInterceptor(VerbosityService verbosityService) : base(verbosityService)
+ public StandardErrorConsoleInterceptor()
{
Instance = this;
}
@@ -33,13 +28,7 @@ public void Initialize()
Console.SetError(this);
}
- private protected override TextWriter GetOriginalOut()
- {
- return DefaultError;
- }
+ private protected override TextWriter GetOriginalOut() => DefaultError;
- private protected override void ResetDefault()
- {
- Console.SetError(DefaultError);
- }
+ private protected override void ResetDefault() => Console.SetError(DefaultError);
}
diff --git a/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs b/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
index d494fee6aa..075054faa2 100644
--- a/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
+++ b/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
@@ -1,7 +1,4 @@
-using TUnit.Core;
-using TUnit.Engine.Services;
-
-#pragma warning disable CS8765 // Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes).
+using TUnit.Core.Logging;
namespace TUnit.Engine.Logging;
@@ -11,19 +8,17 @@ internal class StandardOutConsoleInterceptor : OptimizedConsoleInterceptor
public static TextWriter DefaultOut { get; }
- protected override TextWriter RedirectedOut => Context.Current.OutputWriter;
+ protected override LogLevel SinkLogLevel => LogLevel.Information;
static StandardOutConsoleInterceptor()
{
- // Get the raw stream without SyncTextWriter synchronization wrapper
- // BufferedTextWriter already provides thread safety, so we avoid double-locking
DefaultOut = new StreamWriter(Console.OpenStandardOutput())
{
AutoFlush = true
};
}
- public StandardOutConsoleInterceptor(VerbosityService verbosityService) : base(verbosityService)
+ public StandardOutConsoleInterceptor()
{
Instance = this;
}
@@ -33,13 +28,7 @@ public void Initialize()
Console.SetOut(this);
}
- private protected override TextWriter GetOriginalOut()
- {
- return DefaultOut;
- }
+ private protected override TextWriter GetOriginalOut() => DefaultOut;
- private protected override void ResetDefault()
- {
- Console.SetOut(DefaultOut);
- }
+ private protected override void ResetDefault() => Console.SetOut(DefaultOut);
}
diff --git a/TUnit.Engine/Logging/TestOutputSink.cs b/TUnit.Engine/Logging/TestOutputSink.cs
new file mode 100644
index 0000000000..7f7426e843
--- /dev/null
+++ b/TUnit.Engine/Logging/TestOutputSink.cs
@@ -0,0 +1,31 @@
+using TUnit.Core;
+using TUnit.Core.Logging;
+
+namespace TUnit.Engine.Logging;
+
+///
+/// A log sink that accumulates output to the test context's output writers.
+/// Routes to OutputWriter for non-error levels and ErrorOutputWriter for error levels.
+/// This captured output is included in test results.
+///
+internal sealed class TestOutputSink : ILogSink
+{
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ if (context == null)
+ {
+ return;
+ }
+
+ var writer = level >= LogLevel.Error ? context.ErrorOutputWriter : context.OutputWriter;
+ writer.WriteLine(message);
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Log(level, message, exception, context);
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/TUnit.Engine/NullLogger.cs b/TUnit.Engine/MtpNullLogger.cs
similarity index 73%
rename from TUnit.Engine/NullLogger.cs
rename to TUnit.Engine/MtpNullLogger.cs
index aa54c78501..a04e1564ae 100644
--- a/TUnit.Engine/NullLogger.cs
+++ b/TUnit.Engine/MtpNullLogger.cs
@@ -2,7 +2,10 @@
namespace TUnit.Engine;
-internal class NullLogger : ILogger
+///
+/// Null logger implementation for Microsoft Testing Platform's ILogger interface.
+///
+internal class MtpNullLogger : ILogger
{
public Task LogAsync(LogLevel logLevel, TState state, Exception? exception, Func formatter)
=> Task.CompletedTask;
diff --git a/TUnit.Engine/NullLoggerFactory.cs b/TUnit.Engine/NullLoggerFactory.cs
index 105c7e7679..79e6c1f5fd 100644
--- a/TUnit.Engine/NullLoggerFactory.cs
+++ b/TUnit.Engine/NullLoggerFactory.cs
@@ -7,6 +7,6 @@ namespace TUnit.Engine;
///
internal class NullLoggerFactory : ILoggerFactory
{
- public ILogger CreateLogger() => new NullLogger();
- public ILogger CreateLogger(string categoryName) => new NullLogger