From 18a954b60b39bfc49f91b5048d8fcc1f1ef61047 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:13:46 +0000
Subject: [PATCH 01/25] docs: add log streaming plugin system design
Design for #4478 - enables real-time log streaming to IDEs during test
execution via a plugin-based ILogSink system inspired by ASP.NET Core.
---
docs/plans/2026-01-17-log-streaming-design.md | 407 ++++++++++++++++++
1 file changed, 407 insertions(+)
create mode 100644 docs/plans/2026-01-17-log-streaming-design.md
diff --git a/docs/plans/2026-01-17-log-streaming-design.md b/docs/plans/2026-01-17-log-streaming-design.md
new file mode 100644
index 0000000000..7e9fd53c30
--- /dev/null
+++ b/docs/plans/2026-01-17-log-streaming-design.md
@@ -0,0 +1,407 @@
+# Log Streaming Plugin System Design
+
+**Date:** 2026-01-17
+**Issue:** [#4478 - Stream logs](https://github.com/thomhurst/TUnit/issues/4478)
+**Status:** Draft
+
+## Problem Statement
+
+Currently, when using TUnit's logging with `TestContext.GetDefaultLogger()`, log output only appears in the IDE (e.g., Rider) after test completion. Users expect real-time log streaming during test execution, similar to NUnit's behavior.
+
+```csharp
+[Test]
+public async Task X()
+{
+ for (int i = 0; i < 3; i += 1)
+ {
+ TestContext.Current!.GetDefaultLogger().LogInformation(i.ToString());
+ await Task.Delay(1000);
+ }
+}
+```
+
+**Current behavior:** All 3 log messages appear after the test completes.
+**Expected behavior:** Each log message appears as it's written.
+
+## Root Cause
+
+Microsoft Testing Platform has two output channels:
+1. **Real-time:** `IOutputDevice.DisplayAsync()` - streams directly to IDEs during execution
+2. **Historical:** `StandardOutputProperty` on `TestNodeUpdateMessage` - bundled at test completion
+
+`DefaultLogger` writes to `context.OutputWriter` (historical) and `OriginalConsoleOut` (console), but never uses `IOutputDevice.DisplayAsync()` for real-time IDE streaming.
+
+## Solution: Plugin-Based Log Sink System
+
+Inspired by ASP.NET Core's logging architecture, we'll introduce a plugin system that:
+1. Allows multiple log destinations (sinks)
+2. Enables real-time streaming via `IOutputDevice`
+3. Maintains backward compatibility with historical capture
+4. Opens extensibility for custom sinks (Seq, file, etc.)
+
+## Design
+
+### Core Interfaces (TUnit.Core)
+
+#### ILogSink
+
+```csharp
+namespace TUnit.Core.Logging;
+
+///
+/// Represents a destination for log messages. Implement this interface
+/// to create custom log sinks (e.g., file, Seq, Application Insights).
+///
+public interface ILogSink
+{
+ ///
+ /// Asynchronously logs a message.
+ ///
+ /// The log level.
+ /// The formatted message.
+ /// Optional exception.
+ /// The current context (TestContext, ClassHookContext, etc.), or null if outside test execution.
+ ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context);
+
+ ///
+ /// Synchronously logs a message.
+ ///
+ void Log(LogLevel level, string message, Exception? exception, Context? context);
+
+ ///
+ /// Determines if this sink should receive messages at the specified level.
+ ///
+ bool IsEnabled(LogLevel level);
+}
+```
+
+**Design notes:**
+- Both sync and async methods match existing `ILogger` pattern
+- `Context?` is nullable for console output outside test execution
+- Sinks can cast to `TestContext` when they need test-specific info (test name, class, etc.)
+- `IsEnabled` allows sinks to filter by level for performance
+- If sink implements `IAsyncDisposable`, TUnit calls it at session end
+
+#### TUnitLoggerFactory
+
+```csharp
+namespace TUnit.Core.Logging;
+
+///
+/// Factory for configuring and managing log sinks.
+///
+public static class TUnitLoggerFactory
+{
+ private static readonly List Sinks = new();
+ private static readonly object Lock = new();
+
+ ///
+ /// Registers a log sink to receive log messages.
+ /// Call this in [Before(Assembly)] or before tests run.
+ ///
+ public static void AddSink(ILogSink sink)
+ {
+ lock (Lock)
+ {
+ Sinks.Add(sink);
+ }
+ }
+
+ ///
+ /// Registers a log sink by type. TUnit will instantiate it.
+ ///
+ public static void AddSink() where TSink : ILogSink, new()
+ {
+ AddSink(new TSink());
+ }
+
+ internal static IReadOnlyList GetSinks() => Sinks;
+
+ internal static async ValueTask DisposeAllAsync()
+ {
+ foreach (var sink in Sinks)
+ {
+ if (sink is IAsyncDisposable disposable)
+ {
+ try
+ {
+ await disposable.DisposeAsync();
+ }
+ catch
+ {
+ // Swallow disposal errors
+ }
+ }
+ }
+ Sinks.Clear();
+ }
+}
+```
+
+### Routing Changes
+
+#### DefaultLogger Modifications
+
+```csharp
+// In DefaultLogger.WriteToOutput / WriteToOutputAsync:
+
+protected virtual void WriteToOutput(string message, bool isError)
+{
+ var level = isError ? LogLevel.Error : LogLevel.Information;
+
+ // Historical capture (unchanged)
+ if (isError)
+ context.ErrorOutputWriter.WriteLine(message);
+ else
+ context.OutputWriter.WriteLine(message);
+
+ // Real-time streaming to sinks (new)
+ foreach (var sink in TUnitLoggerFactory.GetSinks())
+ {
+ if (!sink.IsEnabled(level))
+ continue;
+
+ try
+ {
+ sink.Log(level, message, exception: null, context);
+ }
+ catch (Exception ex)
+ {
+ GlobalContext.Current.OriginalConsoleError.WriteLine(
+ $"[TUnit] Log sink {sink.GetType().Name} failed: {ex.Message}");
+ }
+ }
+}
+
+protected virtual async ValueTask WriteToOutputAsync(string message, bool isError)
+{
+ var level = isError ? LogLevel.Error : LogLevel.Information;
+
+ // Historical capture (unchanged)
+ if (isError)
+ await context.ErrorOutputWriter.WriteLineAsync(message);
+ else
+ await context.OutputWriter.WriteLineAsync(message);
+
+ // Real-time streaming to sinks (new)
+ foreach (var sink in TUnitLoggerFactory.GetSinks())
+ {
+ if (!sink.IsEnabled(level))
+ continue;
+
+ try
+ {
+ await sink.LogAsync(level, message, exception: null, context);
+ }
+ catch (Exception ex)
+ {
+ await GlobalContext.Current.OriginalConsoleError.WriteLineAsync(
+ $"[TUnit] Log sink {sink.GetType().Name} failed: {ex.Message}");
+ }
+ }
+}
+```
+
+#### Console Interceptor Modifications
+
+Route `Console.WriteLine` through sinks for real-time streaming:
+
+```csharp
+// In StandardOutConsoleInterceptor, after writing to context:
+
+private void RouteToSinks(string? value)
+{
+ if (string.IsNullOrEmpty(value))
+ return;
+
+ var sinks = TUnitLoggerFactory.GetSinks();
+ if (sinks.Count == 0)
+ return;
+
+ var context = Context.Current; // may be null outside test execution
+
+ foreach (var sink in sinks)
+ {
+ if (!sink.IsEnabled(LogLevel.Information))
+ continue;
+
+ try
+ {
+ sink.Log(LogLevel.Information, value, exception: null, context);
+ }
+ catch (Exception ex)
+ {
+ // Write to original console to avoid recursion
+ GlobalContext.Current.OriginalConsoleError.WriteLine(
+ $"[TUnit] Log sink {sink.GetType().Name} failed: {ex.Message}");
+ }
+ }
+}
+```
+
+### Engine's Built-in Sink (TUnit.Engine)
+
+```csharp
+namespace TUnit.Engine.Logging;
+
+///
+/// Built-in sink that streams logs to IDEs via Microsoft Testing Platform's IOutputDevice.
+/// Automatically registered by TUnit.Engine at startup.
+///
+internal class OutputDeviceLogSink : ILogSink
+{
+ private readonly IOutputDevice _outputDevice;
+ private readonly LogLevel _minLevel;
+
+ public OutputDeviceLogSink(IOutputDevice outputDevice, LogLevel minLevel = LogLevel.Information)
+ {
+ _outputDevice = outputDevice;
+ _minLevel = minLevel;
+ }
+
+ public bool IsEnabled(LogLevel level) => level >= _minLevel;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ // Fire and forget for sync path - IOutputDevice is async-only
+ _ = LogAsync(level, message, exception, context);
+ }
+
+ public async ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ if (!IsEnabled(level))
+ return;
+
+ var color = GetConsoleColor(level);
+
+ await _outputDevice.DisplayAsync(
+ this,
+ new FormattedTextOutputDeviceData(message)
+ {
+ ForegroundColor = new SystemConsoleColor { ConsoleColor = color }
+ },
+ CancellationToken.None);
+ }
+
+ private static ConsoleColor GetConsoleColor(LogLevel level) => level switch
+ {
+ LogLevel.Error => ConsoleColor.Red,
+ LogLevel.Warning => ConsoleColor.Yellow,
+ LogLevel.Debug => ConsoleColor.Gray,
+ _ => ConsoleColor.White
+ };
+}
+```
+
+**Registration during test session initialization:**
+
+```csharp
+// In TUnitTestFramework or test session initialization:
+var outputDevice = serviceProvider.GetRequiredService();
+TUnitLoggerFactory.AddSink(new OutputDeviceLogSink(outputDevice));
+```
+
+## Data Flow
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Test Code │
+│ - TestContext.GetDefaultLogger().LogInformation("...") │
+│ - Console.WriteLine("...") │
+└──────────────────────────┬──────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────┐
+│ DefaultLogger / Console Interceptor │
+│ 1. Write to context.OutputWriter (historical capture) │
+│ 2. Route to all registered ILogSink instances │
+└──────────────────────────┬──────────────────────────────────────┘
+ │
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐
+│ OutputDevice │ │ User's Seq │ │ User's File │
+│ LogSink │ │ LogSink │ │ LogSink │
+│ (built-in) │ │ (custom) │ │ (custom) │
+└────────┬────────┘ └──────┬──────┘ └────────┬────────┘
+ │ │ │
+ ▼ ▼ ▼
+ IDE Real-time Seq Server Log File
+```
+
+## User Registration Example
+
+```csharp
+[assembly: Before(Assembly)]
+public static class LoggingSetup
+{
+ public static Task BeforeAssembly()
+ {
+ // Add custom sinks
+ TUnitLoggerFactory.AddSink(new SeqLogSink("http://localhost:5341"));
+ TUnitLoggerFactory.AddSink();
+ return Task.CompletedTask;
+ }
+}
+
+// Example custom sink
+public class FileLogSink : ILogSink, IAsyncDisposable
+{
+ private readonly StreamWriter _writer;
+
+ public FileLogSink()
+ {
+ _writer = new StreamWriter("test-log.txt", append: true);
+ }
+
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ var testName = (context as TestContext)?.TestDetails.TestName ?? "N/A";
+ _writer.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{level}] [{testName}] {message}");
+ }
+
+ public async ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ var testName = (context as TestContext)?.TestDetails.TestName ?? "N/A";
+ await _writer.WriteLineAsync($"[{DateTime.Now:HH:mm:ss}] [{level}] [{testName}] {message}");
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _writer.DisposeAsync();
+ }
+}
+```
+
+## Files to Create/Modify
+
+| File | Action | Description |
+|------|--------|-------------|
+| `TUnit.Core/Logging/ILogSink.cs` | Create | New sink interface |
+| `TUnit.Core/Logging/TUnitLoggerFactory.cs` | Create | Sink registration |
+| `TUnit.Core/Logging/DefaultLogger.cs` | Modify | Route to sinks |
+| `TUnit.Core/Logging/StandardOutConsoleInterceptor.cs` | Modify | Route console to sinks |
+| `TUnit.Engine/Logging/OutputDeviceLogSink.cs` | Create | Built-in IDE streaming sink |
+| `TUnit.Engine/Services/TUnitTestFramework.cs` | Modify | Register OutputDeviceLogSink |
+
+## Error Handling
+
+- Sink failures are caught and logged to `OriginalConsoleError`
+- Failures do not break tests or stop other sinks from receiving messages
+- Disposal errors are swallowed during cleanup
+
+## Backward Compatibility
+
+- No breaking changes to existing APIs
+- Historical capture via `context.OutputWriter` unchanged
+- Existing behavior preserved if no custom sinks registered
+- `OutputDeviceLogSink` registered automatically by Engine
+
+## Future Considerations
+
+- Built-in sinks package (file, JSON, etc.)
+- Structured logging support with semantic properties
+- Log level configuration per sink
+- Async batching for high-throughput scenarios
From f7e119c6546f9410fbc258e21dd5c508e74b3ec6 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:15:39 +0000
Subject: [PATCH 02/25] fix: correct .worktrees gitignore pattern
---
.gitignore | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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/
From 06a6b16bcc0bcfdb3902224a7f063021cf01aa64 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:20:55 +0000
Subject: [PATCH 03/25] docs: add log streaming implementation plan
---
...2026-01-17-log-streaming-implementation.md | 999 ++++++++++++++++++
1 file changed, 999 insertions(+)
create mode 100644 docs/plans/2026-01-17-log-streaming-implementation.md
diff --git a/docs/plans/2026-01-17-log-streaming-implementation.md b/docs/plans/2026-01-17-log-streaming-implementation.md
new file mode 100644
index 0000000000..ff08669d03
--- /dev/null
+++ b/docs/plans/2026-01-17-log-streaming-implementation.md
@@ -0,0 +1,999 @@
+# Log Streaming Plugin System Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Enable real-time log streaming to IDEs during test execution via a plugin-based ILogSink system.
+
+**Architecture:** Introduce `ILogSink` interface in TUnit.Core that receives log messages. `TUnitLoggerFactory` manages sink registration. `DefaultLogger` and console interceptors route to all registered sinks. TUnit.Engine registers `OutputDeviceLogSink` at startup which uses `IOutputDevice.DisplayAsync()` for real-time IDE streaming.
+
+**Tech Stack:** .NET, Microsoft Testing Platform, IOutputDevice
+
+**Design Document:** `docs/plans/2026-01-17-log-streaming-design.md`
+
+---
+
+## Task 1: Create ILogSink Interface
+
+**Files:**
+- Create: `TUnit.Core/Logging/ILogSink.cs`
+
+**Step 1: Create the interface file**
+
+```csharp
+namespace TUnit.Core.Logging;
+
+///
+/// Represents a destination for log messages. Implement this interface
+/// to create custom log sinks (e.g., file, Seq, Application Insights).
+///
+public interface ILogSink
+{
+ ///
+ /// Asynchronously logs a message.
+ ///
+ /// The log level.
+ /// The formatted message.
+ /// Optional exception.
+ /// The current context (TestContext, ClassHookContext, etc.), or null if outside test execution.
+ ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context);
+
+ ///
+ /// Synchronously logs a message.
+ ///
+ void Log(LogLevel level, string message, Exception? exception, Context? context);
+
+ ///
+ /// Determines if this sink should receive messages at the specified level.
+ ///
+ bool IsEnabled(LogLevel level);
+}
+```
+
+**Step 2: Verify it compiles**
+
+Run: `dotnet build TUnit.Core/TUnit.Core.csproj -c Release`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add TUnit.Core/Logging/ILogSink.cs
+git commit -m "feat(logging): add ILogSink interface for log destinations"
+```
+
+---
+
+## Task 2: Create TUnitLoggerFactory
+
+**Files:**
+- Create: `TUnit.Core/Logging/TUnitLoggerFactory.cs`
+
+**Step 1: Create the factory class**
+
+```csharp
+namespace TUnit.Core.Logging;
+
+///
+/// Factory for configuring and managing log sinks.
+///
+public static class TUnitLoggerFactory
+{
+ private static readonly List _sinks = [];
+ private static readonly Lock _lock = new();
+
+ ///
+ /// Registers a log sink to receive log messages.
+ /// Call this in [Before(Assembly)] or before tests run.
+ ///
+ public static void AddSink(ILogSink sink)
+ {
+ lock (_lock)
+ {
+ _sinks.Add(sink);
+ }
+ }
+
+ ///
+ /// Registers a log sink by type. TUnit will instantiate it.
+ ///
+ 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.
+ /// 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)
+ {
+ if (sink is IAsyncDisposable disposable)
+ {
+ try
+ {
+ await disposable.DisposeAsync();
+ }
+ catch
+ {
+ // Swallow disposal errors
+ }
+ }
+ }
+ }
+
+ ///
+ /// Clears all registered sinks. For testing purposes.
+ ///
+ internal static void Clear()
+ {
+ lock (_lock)
+ {
+ _sinks.Clear();
+ }
+ }
+}
+```
+
+**Step 2: Verify it compiles**
+
+Run: `dotnet build TUnit.Core/TUnit.Core.csproj -c Release`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add TUnit.Core/Logging/TUnitLoggerFactory.cs
+git commit -m "feat(logging): add TUnitLoggerFactory for sink registration"
+```
+
+---
+
+## Task 3: Add Internal Sink Routing Helper
+
+**Files:**
+- Create: `TUnit.Core/Logging/LogSinkRouter.cs`
+
+**Step 1: Create router helper to avoid code duplication**
+
+```csharp
+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);
+ }
+ catch (Exception ex)
+ {
+ // Write to original console to avoid recursion
+ await GlobalContext.Current.OriginalConsoleError.WriteLineAsync(
+ $"[TUnit] Log sink {sink.GetType().Name} failed: {ex.Message}");
+ }
+ }
+ }
+}
+```
+
+**Step 2: Verify it compiles**
+
+Run: `dotnet build TUnit.Core/TUnit.Core.csproj -c Release`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add TUnit.Core/Logging/LogSinkRouter.cs
+git commit -m "feat(logging): add LogSinkRouter helper for sink dispatch"
+```
+
+---
+
+## Task 4: Modify DefaultLogger to Route to Sinks
+
+**Files:**
+- Modify: `TUnit.Core/Logging/DefaultLogger.cs`
+
+**Step 1: Update WriteToOutput to route to sinks**
+
+Find the `WriteToOutput` method (around line 125) and replace with:
+
+```csharp
+///
+/// Writes the message to the output.
+/// Override this method to customize how messages are written.
+///
+/// The formatted message to write.
+/// True if this is an error-level message.
+protected virtual void WriteToOutput(string message, bool isError)
+{
+ var level = isError ? LogLevel.Error : LogLevel.Information;
+
+ // Historical capture
+ if (isError)
+ {
+ context.ErrorOutputWriter.WriteLine(message);
+ }
+ else
+ {
+ context.OutputWriter.WriteLine(message);
+ }
+
+ // Real-time streaming to sinks
+ LogSinkRouter.RouteToSinks(level, message, null, context);
+}
+```
+
+**Step 2: Update WriteToOutputAsync to route to sinks**
+
+Find the `WriteToOutputAsync` method (around line 146) and replace with:
+
+```csharp
+///
+/// Asynchronously writes the message to the output.
+/// Override this method to customize how messages are written.
+///
+/// The formatted message to write.
+/// True if this is an error-level message.
+/// A task representing the async operation.
+protected virtual async ValueTask WriteToOutputAsync(string message, bool isError)
+{
+ var level = isError ? LogLevel.Error : LogLevel.Information;
+
+ // Historical capture
+ if (isError)
+ {
+ await context.ErrorOutputWriter.WriteLineAsync(message);
+ }
+ else
+ {
+ await context.OutputWriter.WriteLineAsync(message);
+ }
+
+ // Real-time streaming to sinks
+ await LogSinkRouter.RouteToSinksAsync(level, message, null, context);
+}
+```
+
+**Step 3: Verify it compiles**
+
+Run: `dotnet build TUnit.Core/TUnit.Core.csproj -c Release`
+Expected: Build succeeded
+
+**Step 4: Commit**
+
+```bash
+git add TUnit.Core/Logging/DefaultLogger.cs
+git commit -m "feat(logging): route DefaultLogger output to registered sinks"
+```
+
+---
+
+## Task 5: Modify Console Interceptor to Route to Sinks
+
+**Files:**
+- Modify: `TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs`
+
+**Step 1: Find the interceptor and understand its structure**
+
+Read the file first to understand how it intercepts console output.
+
+Run: Read `TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs`
+
+**Step 2: Add sink routing after console capture**
+
+The interceptor likely has Write/WriteLine methods that capture output. Add routing to sinks after capturing. The exact modification depends on the file structure, but the pattern is:
+
+After any line that writes to the context's output (like `Context.Current?.OutputWriter?.WriteLine(value)`), add:
+
+```csharp
+// Route to sinks for real-time streaming
+LogSinkRouter.RouteToSinks(LogLevel.Information, value?.ToString() ?? string.Empty, null, Context.Current);
+```
+
+**Step 3: Add using statement if needed**
+
+Add at top of file:
+```csharp
+using TUnit.Core.Logging;
+```
+
+**Step 4: Verify it compiles**
+
+Run: `dotnet build TUnit.Engine/TUnit.Engine.csproj -c Release`
+Expected: Build succeeded
+
+**Step 5: Commit**
+
+```bash
+git add TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
+git commit -m "feat(logging): route Console.WriteLine to registered sinks"
+```
+
+---
+
+## Task 6: Modify Console Error Interceptor (if separate)
+
+**Files:**
+- Modify: `TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs` (if exists)
+
+**Step 1: Check if file exists and apply same pattern**
+
+If there's a separate error interceptor, apply the same changes as Task 5 but use `LogLevel.Error`:
+
+```csharp
+LogSinkRouter.RouteToSinks(LogLevel.Error, value?.ToString() ?? string.Empty, null, Context.Current);
+```
+
+**Step 2: Verify it compiles**
+
+Run: `dotnet build TUnit.Engine/TUnit.Engine.csproj -c Release`
+Expected: Build succeeded
+
+**Step 3: Commit (if changes made)**
+
+```bash
+git add TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
+git commit -m "feat(logging): route Console.Error to registered sinks"
+```
+
+---
+
+## Task 7: Create OutputDeviceLogSink in TUnit.Engine
+
+**Files:**
+- Create: `TUnit.Engine/Logging/OutputDeviceLogSink.cs`
+
+**Step 1: Create the sink that streams to IDEs**
+
+```csharp
+using Microsoft.Testing.Platform.OutputDevice;
+using TUnit.Core;
+using TUnit.Core.Logging;
+
+namespace TUnit.Engine.Logging;
+
+///
+/// Built-in sink that streams logs to IDEs via Microsoft Testing Platform's IOutputDevice.
+/// Automatically registered by TUnit.Engine at startup.
+///
+internal class OutputDeviceLogSink : ILogSink, IOutputDeviceDataProducer
+{
+ private readonly IOutputDevice _outputDevice;
+ private readonly LogLevel _minLevel;
+
+ public OutputDeviceLogSink(IOutputDevice outputDevice, LogLevel minLevel = LogLevel.Information)
+ {
+ _outputDevice = outputDevice;
+ _minLevel = minLevel;
+ }
+
+ public string Uid => "TUnit.OutputDeviceLogSink";
+ public string Version => typeof(OutputDeviceLogSink).Assembly.GetName().Version?.ToString() ?? "1.0.0";
+ public string DisplayName => "TUnit Log Sink";
+ public string Description => "Streams test logs to IDE in real-time";
+
+ public Task IsEnabledAsync() => Task.FromResult(true);
+
+ public bool IsEnabled(LogLevel level) => level >= _minLevel;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ if (!IsEnabled(level))
+ {
+ return;
+ }
+
+ // Fire and forget for sync path - IOutputDevice is async-only
+ _ = LogAsync(level, message, exception, context);
+ }
+
+ public async ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ if (!IsEnabled(level))
+ {
+ return;
+ }
+
+ try
+ {
+ var color = GetConsoleColor(level);
+
+ await _outputDevice.DisplayAsync(
+ this,
+ new FormattedTextOutputDeviceData(message)
+ {
+ ForegroundColor = new SystemConsoleColor { ConsoleColor = color }
+ },
+ CancellationToken.None);
+ }
+ catch
+ {
+ // Swallow errors - logging should not break tests
+ }
+ }
+
+ private static ConsoleColor GetConsoleColor(LogLevel level) => level switch
+ {
+ LogLevel.Error => ConsoleColor.Red,
+ LogLevel.Warning => ConsoleColor.Yellow,
+ LogLevel.Debug => ConsoleColor.Gray,
+ LogLevel.Trace => ConsoleColor.DarkGray,
+ _ => ConsoleColor.White
+ };
+}
+```
+
+**Step 2: Verify it compiles**
+
+Run: `dotnet build TUnit.Engine/TUnit.Engine.csproj -c Release`
+Expected: Build succeeded
+
+**Step 3: Commit**
+
+```bash
+git add TUnit.Engine/Logging/OutputDeviceLogSink.cs
+git commit -m "feat(logging): add OutputDeviceLogSink for real-time IDE streaming"
+```
+
+---
+
+## Task 8: Register OutputDeviceLogSink at Startup
+
+**Files:**
+- Modify: Find the test framework initialization (likely `TUnit.Engine/Services/TUnitTestFramework.cs` or similar)
+
+**Step 1: Find where IOutputDevice is available**
+
+Search for where `IOutputDevice` is injected or retrieved from the service provider.
+
+Run: `grep -r "IOutputDevice" TUnit.Engine/ --include="*.cs"`
+
+**Step 2: Register the sink during initialization**
+
+At the point where `IOutputDevice` is available (likely in a constructor or initialization method), add:
+
+```csharp
+// Register the built-in sink for real-time IDE streaming
+var outputDeviceSink = new OutputDeviceLogSink(outputDevice);
+TUnitLoggerFactory.AddSink(outputDeviceSink);
+```
+
+Add using statement:
+```csharp
+using TUnit.Core.Logging;
+```
+
+**Step 3: Verify it compiles**
+
+Run: `dotnet build TUnit.Engine/TUnit.Engine.csproj -c Release`
+Expected: Build succeeded
+
+**Step 4: Commit**
+
+```bash
+git add TUnit.Engine/Services/*.cs
+git commit -m "feat(logging): register OutputDeviceLogSink at test session startup"
+```
+
+---
+
+## Task 9: Dispose Sinks at Session End
+
+**Files:**
+- Modify: Find session cleanup code (likely `TUnit.Engine/Services/TUnitTestFramework.cs` or `OnTestSessionFinishing` handler)
+
+**Step 1: Find session end hook**
+
+Search for cleanup or disposal code:
+
+Run: `grep -r "OnTestSessionFinishing\|Dispose\|Cleanup" TUnit.Engine/Services/ --include="*.cs"`
+
+**Step 2: Add sink disposal**
+
+At session end, add:
+
+```csharp
+await TUnitLoggerFactory.DisposeAllAsync();
+```
+
+**Step 3: Verify it compiles**
+
+Run: `dotnet build TUnit.Engine/TUnit.Engine.csproj -c Release`
+Expected: Build succeeded
+
+**Step 4: Commit**
+
+```bash
+git add TUnit.Engine/Services/*.cs
+git commit -m "feat(logging): dispose sinks at test session end"
+```
+
+---
+
+## Task 10: Write Unit Tests for TUnitLoggerFactory
+
+**Files:**
+- Create: `TUnit.UnitTests/LogSinkTests.cs`
+
+**Step 1: Create test file with basic tests**
+
+```csharp
+using TUnit.Core.Logging;
+
+namespace TUnit.UnitTests;
+
+public class LogSinkTests
+{
+ [Before(Test)]
+ public void Setup()
+ {
+ TUnitLoggerFactory.Clear();
+ }
+
+ [After(Test)]
+ public void Cleanup()
+ {
+ TUnitLoggerFactory.Clear();
+ }
+
+ [Test]
+ public void AddSink_RegistersSink()
+ {
+ // Arrange
+ var sink = new TestLogSink();
+
+ // Act
+ TUnitLoggerFactory.AddSink(sink);
+
+ // Assert
+ var sinks = TUnitLoggerFactory.GetSinks();
+ await Assert.That(sinks).Contains(sink);
+ }
+
+ [Test]
+ public void AddSink_Generic_CreatesSinkInstance()
+ {
+ // Act
+ TUnitLoggerFactory.AddSink();
+
+ // Assert
+ var sinks = TUnitLoggerFactory.GetSinks();
+ await Assert.That(sinks).HasCount().EqualTo(1);
+ await Assert.That(sinks[0]).IsTypeOf();
+ }
+
+ [Test]
+ public async Task DisposeAllAsync_DisposesAsyncDisposableSinks()
+ {
+ // Arrange
+ var sink = new DisposableTestLogSink();
+ TUnitLoggerFactory.AddSink(sink);
+
+ // Act
+ await TUnitLoggerFactory.DisposeAllAsync();
+
+ // Assert
+ await Assert.That(sink.WasDisposed).IsTrue();
+ await Assert.That(TUnitLoggerFactory.GetSinks()).IsEmpty();
+ }
+
+ [Test]
+ public void Clear_RemovesAllSinks()
+ {
+ // Arrange
+ TUnitLoggerFactory.AddSink(new TestLogSink());
+ TUnitLoggerFactory.AddSink(new TestLogSink());
+
+ // Act
+ TUnitLoggerFactory.Clear();
+
+ // Assert
+ await Assert.That(TUnitLoggerFactory.GetSinks()).IsEmpty();
+ }
+
+ private class TestLogSink : ILogSink
+ {
+ public List Messages { get; } = [];
+
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Messages.Add(message);
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Messages.Add(message);
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private class DisposableTestLogSink : TestLogSink, IAsyncDisposable
+ {
+ public bool WasDisposed { get; private set; }
+
+ public ValueTask DisposeAsync()
+ {
+ WasDisposed = true;
+ return ValueTask.CompletedTask;
+ }
+ }
+}
+```
+
+**Step 2: Run tests**
+
+Run: `dotnet run --project TUnit.UnitTests/TUnit.UnitTests.csproj -c Release --no-build -f net8.0 -- --treenode-filter "/*/*/LogSinkTests/*"`
+Expected: All tests pass
+
+**Step 3: Commit**
+
+```bash
+git add TUnit.UnitTests/LogSinkTests.cs
+git commit -m "test(logging): add unit tests for TUnitLoggerFactory"
+```
+
+---
+
+## Task 11: Write Unit Tests for LogSinkRouter
+
+**Files:**
+- Modify: `TUnit.UnitTests/LogSinkTests.cs`
+
+**Step 1: Add router tests to the test file**
+
+```csharp
+public class LogSinkRouterTests
+{
+ [Before(Test)]
+ public void Setup()
+ {
+ TUnitLoggerFactory.Clear();
+ }
+
+ [After(Test)]
+ public void Cleanup()
+ {
+ TUnitLoggerFactory.Clear();
+ }
+
+ [Test]
+ public void RouteToSinks_SendsMessageToAllEnabledSinks()
+ {
+ // Arrange
+ var sink1 = new TestLogSink();
+ var sink2 = new TestLogSink();
+ TUnitLoggerFactory.AddSink(sink1);
+ TUnitLoggerFactory.AddSink(sink2);
+
+ // Act
+ LogSinkRouter.RouteToSinks(LogLevel.Information, "test message", null, null);
+
+ // Assert
+ await Assert.That(sink1.Messages).Contains("test message");
+ await Assert.That(sink2.Messages).Contains("test message");
+ }
+
+ [Test]
+ public void RouteToSinks_SkipsDisabledSinks()
+ {
+ // Arrange
+ var enabledSink = new TestLogSink();
+ var disabledSink = new TestLogSink { MinLevel = LogLevel.Error };
+ TUnitLoggerFactory.AddSink(enabledSink);
+ TUnitLoggerFactory.AddSink(disabledSink);
+
+ // Act
+ LogSinkRouter.RouteToSinks(LogLevel.Information, "test message", null, null);
+
+ // Assert
+ await Assert.That(enabledSink.Messages).Contains("test message");
+ await Assert.That(disabledSink.Messages).IsEmpty();
+ }
+
+ [Test]
+ public void RouteToSinks_ContinuesAfterSinkFailure()
+ {
+ // Arrange
+ var failingSink = new FailingLogSink();
+ var workingSink = new TestLogSink();
+ TUnitLoggerFactory.AddSink(failingSink);
+ TUnitLoggerFactory.AddSink(workingSink);
+
+ // Act - should not throw
+ LogSinkRouter.RouteToSinks(LogLevel.Information, "test message", null, null);
+
+ // Assert - working sink still received message
+ await Assert.That(workingSink.Messages).Contains("test message");
+ }
+
+ private class TestLogSink : ILogSink
+ {
+ public List Messages { get; } = [];
+ public LogLevel MinLevel { get; set; } = LogLevel.Trace;
+
+ public bool IsEnabled(LogLevel level) => level >= MinLevel;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Messages.Add(message);
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Messages.Add(message);
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private class FailingLogSink : ILogSink
+ {
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ throw new InvalidOperationException("Sink failed");
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ throw new InvalidOperationException("Sink failed");
+ }
+ }
+}
+```
+
+**Step 2: Run tests**
+
+Run: `dotnet run --project TUnit.UnitTests/TUnit.UnitTests.csproj -c Release -f net8.0 -- --treenode-filter "/*/*/LogSinkRouterTests/*"`
+Expected: All tests pass
+
+**Step 3: Commit**
+
+```bash
+git add TUnit.UnitTests/LogSinkTests.cs
+git commit -m "test(logging): add unit tests for LogSinkRouter"
+```
+
+---
+
+## Task 12: Integration Test - End to End
+
+**Files:**
+- Create: `TUnit.UnitTests/LogStreamingIntegrationTests.cs`
+
+**Step 1: Create integration test**
+
+```csharp
+using TUnit.Core.Logging;
+
+namespace TUnit.UnitTests;
+
+public class LogStreamingIntegrationTests
+{
+ [Before(Test)]
+ public void Setup()
+ {
+ TUnitLoggerFactory.Clear();
+ }
+
+ [After(Test)]
+ public void Cleanup()
+ {
+ TUnitLoggerFactory.Clear();
+ }
+
+ [Test]
+ public async Task DefaultLogger_RoutesToRegisteredSinks()
+ {
+ // Arrange
+ var captureSink = new CapturingLogSink();
+ TUnitLoggerFactory.AddSink(captureSink);
+
+ var testContext = TestContext.Current;
+ var logger = testContext!.GetDefaultLogger();
+
+ // Act
+ await logger.LogInformationAsync("Hello from test");
+
+ // Assert
+ await Assert.That(captureSink.Messages).Contains(m => m.Contains("Hello from test"));
+ }
+
+ private class CapturingLogSink : ILogSink
+ {
+ public List Messages { get; } = [];
+
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Messages.Add(message);
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Messages.Add(message);
+ return ValueTask.CompletedTask;
+ }
+ }
+}
+```
+
+**Step 2: Run test**
+
+Run: `dotnet run --project TUnit.UnitTests/TUnit.UnitTests.csproj -c Release -f net8.0 -- --treenode-filter "/*/*/LogStreamingIntegrationTests/*"`
+Expected: Test passes
+
+**Step 3: Commit**
+
+```bash
+git add TUnit.UnitTests/LogStreamingIntegrationTests.cs
+git commit -m "test(logging): add integration test for log streaming"
+```
+
+---
+
+## Task 13: Run Full Test Suite
+
+**Files:** None (verification only)
+
+**Step 1: Build entire solution**
+
+Run: `dotnet build TUnit.sln -c Release`
+Expected: Build succeeded
+
+**Step 2: Run unit tests**
+
+Run: `dotnet run --project TUnit.UnitTests/TUnit.UnitTests.csproj -c Release --no-build -f net8.0`
+Expected: All tests pass
+
+**Step 3: Run analyzer tests**
+
+Run: `dotnet run --project TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj -c Release -f net8.0`
+Expected: All tests pass
+
+---
+
+## Task 14: Update Public API Surface (if using PublicAPI analyzers)
+
+**Files:**
+- Modify: `TUnit.Core/PublicAPI.Shipped.txt` or `PublicAPI.Unshipped.txt`
+
+**Step 1: Check if public API tracking is used**
+
+Run: `ls TUnit.Core/PublicAPI*.txt 2>/dev/null || echo "No PublicAPI files"`
+
+**Step 2: If files exist, add new public types**
+
+Add to `PublicAPI.Unshipped.txt`:
+```
+TUnit.Core.Logging.ILogSink
+TUnit.Core.Logging.ILogSink.IsEnabled(TUnit.Core.Logging.LogLevel) -> bool
+TUnit.Core.Logging.ILogSink.Log(TUnit.Core.Logging.LogLevel, string!, System.Exception?, TUnit.Core.Context?) -> void
+TUnit.Core.Logging.ILogSink.LogAsync(TUnit.Core.Logging.LogLevel, string!, System.Exception?, TUnit.Core.Context?) -> System.Threading.Tasks.ValueTask
+TUnit.Core.Logging.TUnitLoggerFactory
+TUnit.Core.Logging.TUnitLoggerFactory.AddSink(TUnit.Core.Logging.ILogSink!) -> void
+TUnit.Core.Logging.TUnitLoggerFactory.AddSink() -> void
+```
+
+**Step 3: Commit**
+
+```bash
+git add TUnit.Core/PublicAPI*.txt
+git commit -m "docs: update public API surface for log sink types"
+```
+
+---
+
+## Task 15: Final Verification and Squash (Optional)
+
+**Step 1: Verify all tests pass**
+
+Run: `dotnet run --project TUnit.UnitTests/TUnit.UnitTests.csproj -c Release -f net8.0`
+Expected: All tests pass including new log sink tests
+
+**Step 2: Review git log**
+
+Run: `git log --oneline -15`
+
+**Step 3: Create final summary commit or squash if desired**
+
+If keeping granular commits:
+```bash
+git push -u origin feature/log-streaming
+```
+
+If squashing:
+```bash
+git rebase -i main
+# Squash commits as desired
+git push -u origin feature/log-streaming
+```
+
+---
+
+## Summary
+
+| Task | Description | Files |
+|------|-------------|-------|
+| 1 | Create ILogSink interface | `TUnit.Core/Logging/ILogSink.cs` |
+| 2 | Create TUnitLoggerFactory | `TUnit.Core/Logging/TUnitLoggerFactory.cs` |
+| 3 | Create LogSinkRouter helper | `TUnit.Core/Logging/LogSinkRouter.cs` |
+| 4 | Modify DefaultLogger | `TUnit.Core/Logging/DefaultLogger.cs` |
+| 5-6 | Modify Console Interceptors | `TUnit.Engine/Logging/Standard*ConsoleInterceptor.cs` |
+| 7 | Create OutputDeviceLogSink | `TUnit.Engine/Logging/OutputDeviceLogSink.cs` |
+| 8 | Register sink at startup | `TUnit.Engine/Services/*.cs` |
+| 9 | Dispose sinks at session end | `TUnit.Engine/Services/*.cs` |
+| 10-12 | Write tests | `TUnit.UnitTests/LogSink*.cs` |
+| 13 | Full test suite verification | - |
+| 14 | Update public API | `TUnit.Core/PublicAPI*.txt` |
+| 15 | Final verification | - |
From 77cb4effcc2f89132ba20df9369401e1d342bb74 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:23:04 +0000
Subject: [PATCH 04/25] feat(logging): add ILogSink interface for log
destinations
---
TUnit.Core/Logging/ILogSink.cs | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
create mode 100644 TUnit.Core/Logging/ILogSink.cs
diff --git a/TUnit.Core/Logging/ILogSink.cs b/TUnit.Core/Logging/ILogSink.cs
new file mode 100644
index 0000000000..2758a9cb78
--- /dev/null
+++ b/TUnit.Core/Logging/ILogSink.cs
@@ -0,0 +1,27 @@
+namespace TUnit.Core.Logging;
+
+///
+/// Represents a destination for log messages. Implement this interface
+/// to create custom log sinks (e.g., file, Seq, Application Insights).
+///
+public interface ILogSink
+{
+ ///
+ /// Asynchronously logs a message.
+ ///
+ /// The log level.
+ /// The formatted message.
+ /// Optional exception.
+ /// The current context (TestContext, ClassHookContext, etc.), or null if outside test execution.
+ ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context);
+
+ ///
+ /// Synchronously logs a message.
+ ///
+ void Log(LogLevel level, string message, Exception? exception, Context? context);
+
+ ///
+ /// Determines if this sink should receive messages at the specified level.
+ ///
+ bool IsEnabled(LogLevel level);
+}
From 0063f09c391b1272a4c7be72301f95fe980e2407 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:26:27 +0000
Subject: [PATCH 05/25] feat(logging): add TUnitLoggerFactory for sink
registration
---
TUnit.Core/Logging/TUnitLoggerFactory.cs | 85 ++++++++++++++++++++++++
1 file changed, 85 insertions(+)
create mode 100644 TUnit.Core/Logging/TUnitLoggerFactory.cs
diff --git a/TUnit.Core/Logging/TUnitLoggerFactory.cs b/TUnit.Core/Logging/TUnitLoggerFactory.cs
new file mode 100644
index 0000000000..b6f027878e
--- /dev/null
+++ b/TUnit.Core/Logging/TUnitLoggerFactory.cs
@@ -0,0 +1,85 @@
+namespace TUnit.Core.Logging;
+
+///
+/// Factory for configuring and managing log sinks.
+///
+public static class TUnitLoggerFactory
+{
+ private static readonly List _sinks = [];
+ private static readonly Lock _lock = new();
+
+ ///
+ /// Registers a log sink to receive log messages.
+ /// Call this in [Before(Assembly)] or before tests run.
+ ///
+ public static void AddSink(ILogSink sink)
+ {
+ lock (_lock)
+ {
+ _sinks.Add(sink);
+ }
+ }
+
+ ///
+ /// Registers a log sink by type. TUnit will instantiate it.
+ ///
+ 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();
+ }
+ }
+}
From c2957c98f5b306b2d363e633e8d49e88fabf7917 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:30:35 +0000
Subject: [PATCH 06/25] feat(logging): add LogSinkRouter helper for sink
dispatch
---
TUnit.Core/Logging/LogSinkRouter.cs | 63 +++++++++++++++++++++++++++++
1 file changed, 63 insertions(+)
create mode 100644 TUnit.Core/Logging/LogSinkRouter.cs
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);
+ }
+ }
+ }
+}
From 5c203ef5154f29103d70594c0c823c4f5f4f6482 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:34:13 +0000
Subject: [PATCH 07/25] feat(logging): route DefaultLogger output to registered
sinks
---
TUnit.Core/Logging/DefaultLogger.cs | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/TUnit.Core/Logging/DefaultLogger.cs b/TUnit.Core/Logging/DefaultLogger.cs
index 9479873ec2..6f530c626a 100644
--- a/TUnit.Core/Logging/DefaultLogger.cs
+++ b/TUnit.Core/Logging/DefaultLogger.cs
@@ -124,16 +124,20 @@ 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)
{
+ var level = isError ? LogLevel.Error : LogLevel.Information;
+
+ // Historical capture
if (isError)
{
context.ErrorOutputWriter.WriteLine(message);
- GlobalContext.Current.OriginalConsoleError.WriteLine(message);
}
else
{
context.OutputWriter.WriteLine(message);
- GlobalContext.Current.OriginalConsoleOut.WriteLine(message);
}
+
+ // Real-time streaming to sinks
+ LogSinkRouter.RouteToSinks(level, message, null, context);
}
///
@@ -145,15 +149,19 @@ protected virtual void WriteToOutput(string message, bool isError)
/// A task representing the async operation.
protected virtual async ValueTask WriteToOutputAsync(string message, bool isError)
{
+ var level = isError ? LogLevel.Error : LogLevel.Information;
+
+ // Historical capture
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);
}
+
+ // Real-time streaming to sinks
+ await LogSinkRouter.RouteToSinksAsync(level, message, null, context);
}
}
From 03b7c31a10c5df7773025c5630b8cbd2ab330719 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:40:29 +0000
Subject: [PATCH 08/25] feat(logging): route Console.WriteLine to registered
sinks
Add sink routing to the console interceptors so that Console.WriteLine
calls stream in real-time to registered log sinks.
- Add abstract SinkLogLevel property to OptimizedConsoleInterceptor
- StandardOutConsoleInterceptor uses LogLevel.Information
- StandardErrorConsoleInterceptor uses LogLevel.Error
- Route all WriteLine methods (sync and async) to LogSinkRouter
- Use both sync RouteToSinks and async RouteToSinksAsync as appropriate
---
.../Logging/OptimizedConsoleInterceptor.cs | 134 +++++++++++++-----
.../StandardErrorConsoleInterceptor.cs | 3 +
.../Logging/StandardOutConsoleInterceptor.cs | 3 +
3 files changed, 108 insertions(+), 32 deletions(-)
diff --git a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
index 86239ace6f..bfa92ed647 100644
--- a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
+++ b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
@@ -1,4 +1,6 @@
using System.Text;
+using TUnit.Core;
+using TUnit.Core.Logging;
using TUnit.Engine.Services;
#pragma warning disable CS8765 // Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes).
@@ -16,9 +18,9 @@ internal abstract class OptimizedConsoleInterceptor : TextWriter
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;
}
@@ -27,10 +29,37 @@ protected OptimizedConsoleInterceptor(VerbosityService verbosityService)
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();
+ ///
+ /// Routes the message to registered log sinks for real-time streaming.
+ ///
+ private void RouteToSinks(string? message)
+ {
+ if (message is not null && message.Length > 0)
+ {
+ LogSinkRouter.RouteToSinks(SinkLogLevel, message, null, Context.Current);
+ }
+ }
+
+ ///
+ /// Routes the message to registered log sinks asynchronously for real-time streaming.
+ ///
+ private async ValueTask RouteToSinksAsync(string? message)
+ {
+ if (message is not null && message.Length > 0)
+ {
+ await LogSinkRouter.RouteToSinksAsync(SinkLogLevel, message, null, Context.Current).ConfigureAwait(false);
+ }
+ }
+
#if NET
public override ValueTask DisposeAsync()
{
@@ -245,20 +274,24 @@ public override void WriteLine()
public override void WriteLine(bool value)
{
+ var str = value.ToString();
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLine(value.ToString());
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(value.ToString());
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(char value)
{
+ var str = value.ToString();
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLine(value.ToString());
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(value.ToString());
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(char[]? buffer)
@@ -274,6 +307,7 @@ public override void WriteLine(char[]? buffer)
_originalOutBuffer?.WriteLine(str);
}
RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(char[] buffer, int index, int count)
@@ -284,42 +318,51 @@ public override void WriteLine(char[] buffer, int index, int count)
_originalOutBuffer?.WriteLine(str);
}
RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(decimal value)
{
+ var str = value.ToString();
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLine(value.ToString());
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(value.ToString());
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(double value)
{
+ var str = value.ToString();
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLine(value.ToString());
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(value.ToString());
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(int value)
{
+ var str = value.ToString();
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLine(value.ToString());
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(value.ToString());
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(long value)
{
+ var str = value.ToString();
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLine(value.ToString());
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(value.ToString());
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(object? value)
@@ -330,15 +373,18 @@ public override void WriteLine(object? value)
_originalOutBuffer?.WriteLine(str);
}
RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(float value)
{
+ var str = value.ToString();
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLine(value.ToString());
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(value.ToString());
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(string? value)
@@ -348,61 +394,74 @@ public override void WriteLine(string? value)
_originalOutBuffer?.WriteLine(value);
}
RedirectedOut?.WriteLine(value);
+ RouteToSinks(value);
}
public override void WriteLine(uint value)
{
+ var str = value.ToString();
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLine(value.ToString());
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(value.ToString());
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(ulong value)
{
+ var str = value.ToString();
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLine(value.ToString());
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(value.ToString());
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
// Optimized formatted WriteLine methods - no tuple allocations
public override void WriteLine(string format, object? arg0)
{
+ var str = string.Format(format, arg0);
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLineFormatted(format, arg0);
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(format, arg0);
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(string format, object? arg0, object? arg1)
{
+ var str = string.Format(format, arg0, arg1);
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLineFormatted(format, arg0, arg1);
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(format, arg0, arg1);
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(string format, object? arg0, object? arg1, object? arg2)
{
+ var str = string.Format(format, arg0, arg1, arg2);
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLineFormatted(format, arg0, arg1, arg2);
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(format, arg0, arg1, arg2);
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(string format, params object?[] arg)
{
+ var str = string.Format(format, arg);
if (!_verbosityService.HideTestOutput)
{
- _originalOutBuffer?.WriteLineFormatted(format, arg);
+ _originalOutBuffer?.WriteLine(str);
}
- RedirectedOut?.WriteLine(format, arg);
+ RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
// Async methods
@@ -462,7 +521,8 @@ public override async Task WriteAsync(string? value)
public override async Task WriteLineAsync(char value)
{
- var str = value + Environment.NewLine;
+ var charStr = value.ToString();
+ var str = charStr + Environment.NewLine;
if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
{
await _originalOutBuffer.WriteAsync(str);
@@ -471,11 +531,13 @@ public override async Task WriteLineAsync(char value)
{
await RedirectedOut.WriteAsync(str);
}
+ await RouteToSinksAsync(charStr).ConfigureAwait(false);
}
public override async Task WriteLineAsync(char[] buffer, int index, int count)
{
- var str = new string(buffer, index, count) + Environment.NewLine;
+ var content = new string(buffer, index, count);
+ var str = content + Environment.NewLine;
if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
{
await _originalOutBuffer.WriteAsync(str);
@@ -484,6 +546,7 @@ public override async Task WriteLineAsync(char[] buffer, int index, int count)
{
await RedirectedOut.WriteAsync(str);
}
+ await RouteToSinksAsync(content).ConfigureAwait(false);
}
public override async Task WriteLineAsync(string? value)
@@ -497,6 +560,7 @@ public override async Task WriteLineAsync(string? value)
{
await RedirectedOut.WriteAsync(str);
}
+ await RouteToSinksAsync(value).ConfigureAwait(false);
}
#if NET
@@ -554,6 +618,7 @@ public override void WriteLine(ReadOnlySpan buffer)
_originalOutBuffer?.WriteLine(str);
}
RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override void WriteLine(StringBuilder? value)
@@ -564,11 +629,13 @@ public override void WriteLine(StringBuilder? value)
_originalOutBuffer?.WriteLine(str);
}
RedirectedOut?.WriteLine(str);
+ RouteToSinks(str);
}
public override async Task WriteLineAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new())
{
- var str = new string(buffer.Span) + Environment.NewLine;
+ var content = new string(buffer.Span);
+ var str = content + Environment.NewLine;
if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
{
await _originalOutBuffer.WriteAsync(str);
@@ -577,11 +644,13 @@ public override void WriteLine(StringBuilder? value)
{
await RedirectedOut.WriteAsync(str);
}
+ await RouteToSinksAsync(content).ConfigureAwait(false);
}
public override async Task WriteLineAsync(StringBuilder? value, CancellationToken cancellationToken = new())
{
- var str = (value?.ToString() ?? string.Empty) + Environment.NewLine;
+ var content = value?.ToString() ?? string.Empty;
+ var str = content + Environment.NewLine;
if (!_verbosityService.HideTestOutput && _originalOutBuffer != null)
{
await _originalOutBuffer.WriteAsync(str);
@@ -590,6 +659,7 @@ public override void WriteLine(StringBuilder? value)
{
await RedirectedOut.WriteAsync(str);
}
+ await RouteToSinksAsync(content).ConfigureAwait(false);
}
#endif
diff --git a/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs b/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
index 2d3080bcae..a19c173fdd 100644
--- a/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
+++ b/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
@@ -1,4 +1,5 @@
using TUnit.Core;
+using TUnit.Core.Logging;
using TUnit.Engine.Services;
#pragma warning disable CS8765 // Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes).
@@ -13,6 +14,8 @@ internal class StandardErrorConsoleInterceptor : OptimizedConsoleInterceptor
protected override TextWriter RedirectedOut => Context.Current.ErrorOutputWriter;
+ protected override LogLevel SinkLogLevel => LogLevel.Error;
+
static StandardErrorConsoleInterceptor()
{
// Get the raw stream without SyncTextWriter synchronization wrapper
diff --git a/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs b/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
index d494fee6aa..bff202930e 100644
--- a/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
+++ b/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
@@ -1,4 +1,5 @@
using TUnit.Core;
+using TUnit.Core.Logging;
using TUnit.Engine.Services;
#pragma warning disable CS8765 // Nullability of type of parameter doesn't match overridden member (possibly because of nullability attributes).
@@ -13,6 +14,8 @@ internal class StandardOutConsoleInterceptor : OptimizedConsoleInterceptor
protected override TextWriter RedirectedOut => Context.Current.OutputWriter;
+ protected override LogLevel SinkLogLevel => LogLevel.Information;
+
static StandardOutConsoleInterceptor()
{
// Get the raw stream without SyncTextWriter synchronization wrapper
From f0f489e7909778704ad8caf6d381a553c78cf171 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:46:43 +0000
Subject: [PATCH 09/25] feat(engine): add OutputDeviceLogSink for real-time IDE
streaming
Add built-in log sink that streams log messages to IDEs (Rider, VS, etc.)
via Microsoft Testing Platform's IOutputDevice.DisplayAsync().
Features:
- Implements ILogSink interface from TUnit.Core.Logging
- Implements IOutputDeviceDataProducer for MTP integration
- Color-coded output by log level (Red=Error, Yellow=Warning, Gray=Debug)
- Configurable minimum log level (default: Information)
- Fire-and-forget sync logging with async implementation
- Exception message formatting appended to log message
---
TUnit.Engine/Logging/OutputDeviceLogSink.cs | 77 +++++++++++++++++++++
1 file changed, 77 insertions(+)
create mode 100644 TUnit.Engine/Logging/OutputDeviceLogSink.cs
diff --git a/TUnit.Engine/Logging/OutputDeviceLogSink.cs b/TUnit.Engine/Logging/OutputDeviceLogSink.cs
new file mode 100644
index 0000000000..eb5db6faa2
--- /dev/null
+++ b/TUnit.Engine/Logging/OutputDeviceLogSink.cs
@@ -0,0 +1,77 @@
+using Microsoft.Testing.Platform.Extensions;
+using Microsoft.Testing.Platform.Extensions.OutputDevice;
+using Microsoft.Testing.Platform.OutputDevice;
+using TUnit.Core;
+using TUnit.Core.Logging;
+
+namespace TUnit.Engine.Logging;
+
+///
+/// A built-in log sink that streams log messages to IDEs (Rider, VS, etc.)
+/// via Microsoft Testing Platform's IOutputDevice.
+///
+internal class OutputDeviceLogSink : ILogSink, IOutputDeviceDataProducer
+{
+ private readonly IOutputDevice _outputDevice;
+ private readonly LogLevel _minLevel;
+ private readonly IExtension _extension;
+
+ public OutputDeviceLogSink(IOutputDevice outputDevice, IExtension extension, LogLevel minLevel = LogLevel.Information)
+ {
+ _outputDevice = outputDevice;
+ _extension = extension;
+ _minLevel = minLevel;
+ }
+
+ public string Uid => _extension.Uid;
+ public string Version => _extension.Version;
+ public string DisplayName => _extension.DisplayName;
+ public string Description => _extension.Description;
+
+ public Task IsEnabledAsync() => Task.FromResult(true);
+
+ public bool IsEnabled(LogLevel level) => level >= _minLevel;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ // Fire and forget - IOutputDevice is async-only
+ _ = LogAsync(level, message, exception, context);
+ }
+
+ public async ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ if (!IsEnabled(level))
+ {
+ return;
+ }
+
+ var formattedMessage = FormatMessage(message, exception);
+ var color = GetConsoleColor(level);
+
+ await _outputDevice.DisplayAsync(
+ this,
+ new FormattedTextOutputDeviceData(formattedMessage)
+ {
+ ForegroundColor = new SystemConsoleColor { ConsoleColor = color }
+ },
+ CancellationToken.None).ConfigureAwait(false);
+ }
+
+ private static string FormatMessage(string message, Exception? exception)
+ {
+ if (exception is null)
+ {
+ return message;
+ }
+
+ return $"{message}{Environment.NewLine}{exception}";
+ }
+
+ private static ConsoleColor GetConsoleColor(LogLevel level) => level switch
+ {
+ LogLevel.Error => ConsoleColor.Red,
+ LogLevel.Warning => ConsoleColor.Yellow,
+ LogLevel.Debug => ConsoleColor.Gray,
+ _ => ConsoleColor.White
+ };
+}
From 10da366ef0648f549a181c49233ebf98b82c4def Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:50:32 +0000
Subject: [PATCH 10/25] feat(engine): register OutputDeviceLogSink at session
start
---
TUnit.Engine/Framework/TUnitServiceProvider.cs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs
index bff64d228a..8e3b2df543 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;
@@ -103,6 +104,9 @@ public TUnitServiceProvider(IExtension extension,
loggerFactory.CreateLogger(),
logLevelProvider));
+ // Register the built-in log sink for streaming logs to IDEs
+ TUnitLoggerFactory.AddSink(new OutputDeviceLogSink(outputDevice, extension));
+
// Create initialization services using Lazy to break circular dependencies
// No more two-phase initialization with Initialize() calls
var objectGraphDiscoveryService = Register(new ObjectGraphDiscoveryService());
From 8c3b86b7980ad75325e04ab49e8f4c50ca5ccf28 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:53:53 +0000
Subject: [PATCH 11/25] feat(engine): dispose log sinks at session end
---
TUnit.Engine/Framework/TUnitServiceProvider.cs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs
index 8e3b2df543..9ce2f462c6 100644
--- a/TUnit.Engine/Framework/TUnitServiceProvider.cs
+++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs
@@ -311,6 +311,9 @@ public async ValueTask DisposeAsync()
_services.Clear();
+ // Dispose all log sinks (flushes any remaining logs)
+ await TUnitLoggerFactory.DisposeAllAsync().ConfigureAwait(false);
+
TestExtensions.ClearCaches();
}
}
From 30818c03cf0dbadc07801a3a434d8ea86a1bdbd0 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 20:59:30 +0000
Subject: [PATCH 12/25] test: add unit tests for TUnitLoggerFactory
Add comprehensive tests covering:
- AddSink registers sink and makes it available via GetSinks
- AddSink instantiates and registers generic sink types
- GetSinks returns empty list when no sinks registered
- GetSinks returns snapshot (not live reference)
- Clear removes all sinks without disposing them
- DisposeAllAsync disposes IAsyncDisposable sinks
- DisposeAllAsync disposes IDisposable sinks
- DisposeAllAsync prefers IAsyncDisposable over IDisposable
- DisposeAllAsync clears sinks list after disposal
- DisposeAllAsync continues disposing remaining sinks on error
Tests use [NotInParallel] to ensure isolation since
TUnitLoggerFactory has static state.
---
TUnit.UnitTests/TUnitLoggerFactoryTests.cs | 281 +++++++++++++++++++++
1 file changed, 281 insertions(+)
create mode 100644 TUnit.UnitTests/TUnitLoggerFactoryTests.cs
diff --git a/TUnit.UnitTests/TUnitLoggerFactoryTests.cs b/TUnit.UnitTests/TUnitLoggerFactoryTests.cs
new file mode 100644
index 0000000000..7a3c18c4ca
--- /dev/null
+++ b/TUnit.UnitTests/TUnitLoggerFactoryTests.cs
@@ -0,0 +1,281 @@
+using TUnit.Core.Logging;
+
+namespace TUnit.UnitTests;
+
+[NotInParallel]
+public class TUnitLoggerFactoryTests
+{
+ [Before(Test)]
+ public void SetUp()
+ {
+ // Ensure clean state before each test
+ TUnitLoggerFactory.Clear();
+ }
+
+ [After(Test)]
+ public async Task TearDown()
+ {
+ // Ensure clean state after each test
+ await TUnitLoggerFactory.DisposeAllAsync();
+ }
+
+ [Test]
+ public async Task AddSink_RegistersSink()
+ {
+ var sink = new MockLogSink();
+
+ TUnitLoggerFactory.AddSink(sink);
+
+ var sinks = TUnitLoggerFactory.GetSinks();
+ await Assert.That(sinks).Count().IsEqualTo(1);
+ await Assert.That(sinks[0]).IsSameReferenceAs(sink);
+ }
+
+ [Test]
+ public async Task AddSink_Generic_InstantiatesAndRegisters()
+ {
+ TUnitLoggerFactory.AddSink();
+
+ var sinks = TUnitLoggerFactory.GetSinks();
+ await Assert.That(sinks).Count().IsEqualTo(1);
+ await Assert.That(sinks[0]).IsTypeOf();
+ }
+
+ [Test]
+ public async Task GetSinks_ReturnsEmptyList_WhenNoSinksRegistered()
+ {
+ var sinks = TUnitLoggerFactory.GetSinks();
+
+ await Assert.That(sinks).IsEmpty();
+ }
+
+ [Test]
+ public async Task Clear_RemovesAllSinks()
+ {
+ var sink1 = new MockLogSink();
+ var sink2 = new MockLogSink();
+ TUnitLoggerFactory.AddSink(sink1);
+ TUnitLoggerFactory.AddSink(sink2);
+
+ TUnitLoggerFactory.Clear();
+
+ var sinks = TUnitLoggerFactory.GetSinks();
+ await Assert.That(sinks).IsEmpty();
+ }
+
+ [Test]
+ public async Task Clear_DoesNotDisposeSinks()
+ {
+ var disposableSink = new DisposableMockSink();
+ TUnitLoggerFactory.AddSink(disposableSink);
+
+ TUnitLoggerFactory.Clear();
+
+ await Assert.That(disposableSink.Disposed).IsFalse();
+ }
+
+ [Test]
+ public async Task DisposeAllAsync_DisposesAsyncDisposable()
+ {
+ var asyncDisposableSink = new AsyncDisposableMockSink();
+ TUnitLoggerFactory.AddSink(asyncDisposableSink);
+
+ await TUnitLoggerFactory.DisposeAllAsync();
+
+ await Assert.That(asyncDisposableSink.Disposed).IsTrue();
+ }
+
+ [Test]
+ public async Task DisposeAllAsync_DisposesDisposable()
+ {
+ var disposableSink = new DisposableMockSink();
+ TUnitLoggerFactory.AddSink(disposableSink);
+
+ await TUnitLoggerFactory.DisposeAllAsync();
+
+ await Assert.That(disposableSink.Disposed).IsTrue();
+ }
+
+ [Test]
+ public async Task DisposeAllAsync_ClearsSinksList()
+ {
+ var sink = new MockLogSink();
+ TUnitLoggerFactory.AddSink(sink);
+
+ await TUnitLoggerFactory.DisposeAllAsync();
+
+ var sinks = TUnitLoggerFactory.GetSinks();
+ await Assert.That(sinks).IsEmpty();
+ }
+
+ [Test]
+ public async Task DisposeAllAsync_ContinuesOnError()
+ {
+ var faultySink = new FaultyDisposableSink();
+ var goodSink = new AsyncDisposableMockSink();
+ TUnitLoggerFactory.AddSink(faultySink);
+ TUnitLoggerFactory.AddSink(goodSink);
+
+ // Should not throw even though faultySink throws
+ await TUnitLoggerFactory.DisposeAllAsync();
+
+ // Verify the second sink was still disposed despite first one failing
+ await Assert.That(goodSink.Disposed).IsTrue();
+ }
+
+ [Test]
+ public async Task DisposeAllAsync_PrefersAsyncDisposableOverDisposable()
+ {
+ var dualDisposableSink = new DualDisposableMockSink();
+ TUnitLoggerFactory.AddSink(dualDisposableSink);
+
+ await TUnitLoggerFactory.DisposeAllAsync();
+
+ // Should use async dispose, not sync dispose
+ await Assert.That(dualDisposableSink.AsyncDisposed).IsTrue();
+ await Assert.That(dualDisposableSink.SyncDisposed).IsFalse();
+ }
+
+ [Test]
+ public async Task AddSink_MultipleSinks_AllAreRegistered()
+ {
+ var sink1 = new MockLogSink();
+ var sink2 = new MockLogSink();
+ var sink3 = new MockLogSink();
+
+ TUnitLoggerFactory.AddSink(sink1);
+ TUnitLoggerFactory.AddSink(sink2);
+ TUnitLoggerFactory.AddSink(sink3);
+
+ var sinks = TUnitLoggerFactory.GetSinks();
+ await Assert.That(sinks).Count().IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task GetSinks_ReturnsSnapshot_NotLiveList()
+ {
+ var sink1 = new MockLogSink();
+ TUnitLoggerFactory.AddSink(sink1);
+
+ var sinks = TUnitLoggerFactory.GetSinks();
+
+ // Add another sink after getting the snapshot
+ var sink2 = new MockLogSink();
+ TUnitLoggerFactory.AddSink(sink2);
+
+ // Original snapshot should not be affected
+ await Assert.That(sinks).Count().IsEqualTo(1);
+
+ // New call should show both
+ var newSinks = TUnitLoggerFactory.GetSinks();
+ await Assert.That(newSinks).Count().IsEqualTo(2);
+ }
+
+ #region Mock Sinks
+
+ private class MockLogSink : ILogSink
+ {
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private class DisposableMockSink : ILogSink, IDisposable
+ {
+ public bool Disposed { get; private set; }
+
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ Disposed = true;
+ }
+ }
+
+ private class AsyncDisposableMockSink : ILogSink, IAsyncDisposable
+ {
+ public bool Disposed { get; private set; }
+
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ Disposed = true;
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private class DualDisposableMockSink : ILogSink, IAsyncDisposable, IDisposable
+ {
+ public bool AsyncDisposed { get; private set; }
+ public bool SyncDisposed { get; private set; }
+
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ AsyncDisposed = true;
+ return ValueTask.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ SyncDisposed = true;
+ }
+ }
+
+ private class FaultyDisposableSink : ILogSink, IAsyncDisposable
+ {
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ }
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ throw new InvalidOperationException("Simulated disposal failure");
+ }
+ }
+
+ #endregion
+}
From 0ce457135b6239d54a16963fa71c7f46d00e099a Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 21:03:39 +0000
Subject: [PATCH 13/25] test: add unit tests for LogSinkRouter
---
TUnit.UnitTests/LogSinkRouterTests.cs | 262 ++++++++++++++++++++++++++
1 file changed, 262 insertions(+)
create mode 100644 TUnit.UnitTests/LogSinkRouterTests.cs
diff --git a/TUnit.UnitTests/LogSinkRouterTests.cs b/TUnit.UnitTests/LogSinkRouterTests.cs
new file mode 100644
index 0000000000..ea99363f62
--- /dev/null
+++ b/TUnit.UnitTests/LogSinkRouterTests.cs
@@ -0,0 +1,262 @@
+using TUnit.Core.Logging;
+
+namespace TUnit.UnitTests;
+
+[NotInParallel]
+public class LogSinkRouterTests
+{
+ [Before(Test)]
+ public void SetUp()
+ {
+ // Ensure clean state before each test
+ TUnitLoggerFactory.Clear();
+ }
+
+ [After(Test)]
+ public async Task TearDown()
+ {
+ // Ensure clean state after each test
+ await TUnitLoggerFactory.DisposeAllAsync();
+ }
+
+ [Test]
+ public async Task RouteToSinks_CallsAllEnabledSinks()
+ {
+ var sink1 = new RecordingSink();
+ var sink2 = new RecordingSink();
+ TUnitLoggerFactory.AddSink(sink1);
+ TUnitLoggerFactory.AddSink(sink2);
+
+ LogSinkRouter.RouteToSinks(LogLevel.Information, "Test message", null, null);
+
+ await Assert.That(sink1.Logs).Count().IsEqualTo(1);
+ await Assert.That(sink2.Logs).Count().IsEqualTo(1);
+ await Assert.That(sink1.Logs[0].Message).IsEqualTo("Test message");
+ await Assert.That(sink1.Logs[0].Level).IsEqualTo(LogLevel.Information);
+ await Assert.That(sink2.Logs[0].Message).IsEqualTo("Test message");
+ }
+
+ [Test]
+ public async Task RouteToSinks_SkipsDisabledSinks()
+ {
+ var enabledSink = new RecordingSink { Enabled = true };
+ var disabledSink = new RecordingSink { Enabled = false };
+ TUnitLoggerFactory.AddSink(enabledSink);
+ TUnitLoggerFactory.AddSink(disabledSink);
+
+ LogSinkRouter.RouteToSinks(LogLevel.Information, "Test message", null, null);
+
+ await Assert.That(enabledSink.Logs).Count().IsEqualTo(1);
+ await Assert.That(disabledSink.Logs).IsEmpty();
+ }
+
+ [Test]
+ public async Task RouteToSinks_ContinuesOnSinkFailure()
+ {
+ var faultySink = new FaultySink();
+ var goodSink = new RecordingSink();
+ TUnitLoggerFactory.AddSink(faultySink);
+ TUnitLoggerFactory.AddSink(goodSink);
+
+ // Should not throw
+ LogSinkRouter.RouteToSinks(LogLevel.Information, "Test message", null, null);
+
+ // Good sink should still receive the message
+ await Assert.That(goodSink.Logs).Count().IsEqualTo(1);
+ await Assert.That(goodSink.Logs[0].Message).IsEqualTo("Test message");
+ }
+
+ [Test]
+ public async Task RouteToSinks_PassesExceptionToSinks()
+ {
+ var sink = new RecordingSink();
+ TUnitLoggerFactory.AddSink(sink);
+ var exception = new InvalidOperationException("Test exception");
+
+ LogSinkRouter.RouteToSinks(LogLevel.Error, "Error occurred", exception, null);
+
+ await Assert.That(sink.Logs).Count().IsEqualTo(1);
+ await Assert.That(sink.Logs[0].Exception).IsSameReferenceAs(exception);
+ }
+
+ [Test]
+ public async Task RouteToSinks_DoesNothingWhenNoSinksRegistered()
+ {
+ // Should not throw when no sinks are registered
+ LogSinkRouter.RouteToSinks(LogLevel.Information, "Test message", null, null);
+
+ // Just verify no exception was thrown - test passes if we get here
+ await Assert.That(true).IsTrue();
+ }
+
+ [Test]
+ public async Task RouteToSinksAsync_CallsAllEnabledSinks()
+ {
+ var sink1 = new RecordingSink();
+ var sink2 = new RecordingSink();
+ TUnitLoggerFactory.AddSink(sink1);
+ TUnitLoggerFactory.AddSink(sink2);
+
+ await LogSinkRouter.RouteToSinksAsync(LogLevel.Information, "Test message", null, null);
+
+ await Assert.That(sink1.Logs).Count().IsEqualTo(1);
+ await Assert.That(sink2.Logs).Count().IsEqualTo(1);
+ await Assert.That(sink1.Logs[0].Message).IsEqualTo("Test message");
+ await Assert.That(sink1.Logs[0].Level).IsEqualTo(LogLevel.Information);
+ await Assert.That(sink2.Logs[0].Message).IsEqualTo("Test message");
+ }
+
+ [Test]
+ public async Task RouteToSinksAsync_SkipsDisabledSinks()
+ {
+ var enabledSink = new RecordingSink { Enabled = true };
+ var disabledSink = new RecordingSink { Enabled = false };
+ TUnitLoggerFactory.AddSink(enabledSink);
+ TUnitLoggerFactory.AddSink(disabledSink);
+
+ await LogSinkRouter.RouteToSinksAsync(LogLevel.Information, "Test message", null, null);
+
+ await Assert.That(enabledSink.Logs).Count().IsEqualTo(1);
+ await Assert.That(disabledSink.Logs).IsEmpty();
+ }
+
+ [Test]
+ public async Task RouteToSinksAsync_ContinuesOnSinkFailure()
+ {
+ var faultySink = new FaultySink();
+ var goodSink = new RecordingSink();
+ TUnitLoggerFactory.AddSink(faultySink);
+ TUnitLoggerFactory.AddSink(goodSink);
+
+ // Should not throw
+ await LogSinkRouter.RouteToSinksAsync(LogLevel.Information, "Test message", null, null);
+
+ // Good sink should still receive the message
+ await Assert.That(goodSink.Logs).Count().IsEqualTo(1);
+ await Assert.That(goodSink.Logs[0].Message).IsEqualTo("Test message");
+ }
+
+ [Test]
+ public async Task RouteToSinksAsync_PassesExceptionToSinks()
+ {
+ var sink = new RecordingSink();
+ TUnitLoggerFactory.AddSink(sink);
+ var exception = new InvalidOperationException("Test exception");
+
+ await LogSinkRouter.RouteToSinksAsync(LogLevel.Error, "Error occurred", exception, null);
+
+ await Assert.That(sink.Logs).Count().IsEqualTo(1);
+ await Assert.That(sink.Logs[0].Exception).IsSameReferenceAs(exception);
+ }
+
+ [Test]
+ public async Task RouteToSinksAsync_DoesNothingWhenNoSinksRegistered()
+ {
+ // Should not throw when no sinks are registered
+ await LogSinkRouter.RouteToSinksAsync(LogLevel.Information, "Test message", null, null);
+
+ // Just verify no exception was thrown - test passes if we get here
+ await Assert.That(true).IsTrue();
+ }
+
+ [Test]
+ public async Task RouteToSinks_PassesCorrectLogLevel()
+ {
+ var sink = new RecordingSink();
+ TUnitLoggerFactory.AddSink(sink);
+
+ LogSinkRouter.RouteToSinks(LogLevel.Warning, "Warning message", null, null);
+ LogSinkRouter.RouteToSinks(LogLevel.Error, "Error message", null, null);
+ LogSinkRouter.RouteToSinks(LogLevel.Debug, "Debug message", null, null);
+
+ await Assert.That(sink.Logs).Count().IsEqualTo(3);
+ await Assert.That(sink.Logs[0].Level).IsEqualTo(LogLevel.Warning);
+ await Assert.That(sink.Logs[1].Level).IsEqualTo(LogLevel.Error);
+ await Assert.That(sink.Logs[2].Level).IsEqualTo(LogLevel.Debug);
+ }
+
+ [Test]
+ public async Task RouteToSinks_SinkCanFilterByLogLevel()
+ {
+ var errorOnlySink = new LevelFilteredSink(LogLevel.Error);
+ TUnitLoggerFactory.AddSink(errorOnlySink);
+
+ LogSinkRouter.RouteToSinks(LogLevel.Information, "Info message", null, null);
+ LogSinkRouter.RouteToSinks(LogLevel.Warning, "Warning message", null, null);
+ LogSinkRouter.RouteToSinks(LogLevel.Error, "Error message", null, null);
+
+ // Only error message should be logged
+ await Assert.That(errorOnlySink.Logs).Count().IsEqualTo(1);
+ await Assert.That(errorOnlySink.Logs[0].Level).IsEqualTo(LogLevel.Error);
+ }
+
+ [Test]
+ public async Task RouteToSinksAsync_SinkCanFilterByLogLevel()
+ {
+ var errorOnlySink = new LevelFilteredSink(LogLevel.Error);
+ TUnitLoggerFactory.AddSink(errorOnlySink);
+
+ await LogSinkRouter.RouteToSinksAsync(LogLevel.Information, "Info message", null, null);
+ await LogSinkRouter.RouteToSinksAsync(LogLevel.Warning, "Warning message", null, null);
+ await LogSinkRouter.RouteToSinksAsync(LogLevel.Error, "Error message", null, null);
+
+ // Only error message should be logged
+ await Assert.That(errorOnlySink.Logs).Count().IsEqualTo(1);
+ await Assert.That(errorOnlySink.Logs[0].Level).IsEqualTo(LogLevel.Error);
+ }
+
+ #region Mock Sinks
+
+ private class RecordingSink : ILogSink
+ {
+ public List<(LogLevel Level, string Message, Exception? Exception, Context? Context)> Logs { get; } = [];
+ public bool Enabled { get; set; } = true;
+
+ public bool IsEnabled(LogLevel level) => Enabled;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ => Logs.Add((level, message, exception, context));
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Logs.Add((level, message, exception, context));
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private class FaultySink : ILogSink
+ {
+ public bool IsEnabled(LogLevel level) => true;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ => throw new InvalidOperationException("Sink failure");
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ => throw new InvalidOperationException("Sink failure");
+ }
+
+ private class LevelFilteredSink : ILogSink
+ {
+ private readonly LogLevel _minimumLevel;
+
+ public LevelFilteredSink(LogLevel minimumLevel)
+ {
+ _minimumLevel = minimumLevel;
+ }
+
+ public List<(LogLevel Level, string Message, Exception? Exception, Context? Context)> Logs { get; } = [];
+
+ public bool IsEnabled(LogLevel level) => level >= _minimumLevel;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ => Logs.Add((level, message, exception, context));
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Logs.Add((level, message, exception, context));
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ #endregion
+}
From 319912b29e19cb06daf35c3ec5e9858fc540f8e4 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 21:08:11 +0000
Subject: [PATCH 14/25] test: add integration tests for log sink system
Add end-to-end tests verifying that messages logged through
DefaultLogger are properly routed to registered sinks via
LogSinkRouter. Tests cover:
- Basic message routing to registered sink
- Multiple message routing
- Correct log level propagation
- Log level prefixes in messages
- Context passing to sinks
- Async logging support
- Multiple sinks receiving broadcast messages
- Disabled sink filtering
- Level-filtered sink behavior
---
TUnit.UnitTests/LogSinkIntegrationTests.cs | 198 +++++++++++++++++++++
1 file changed, 198 insertions(+)
create mode 100644 TUnit.UnitTests/LogSinkIntegrationTests.cs
diff --git a/TUnit.UnitTests/LogSinkIntegrationTests.cs b/TUnit.UnitTests/LogSinkIntegrationTests.cs
new file mode 100644
index 0000000000..f3b4f2cb8b
--- /dev/null
+++ b/TUnit.UnitTests/LogSinkIntegrationTests.cs
@@ -0,0 +1,198 @@
+using TUnit.Core.Logging;
+
+namespace TUnit.UnitTests;
+
+///
+/// Integration tests that verify the end-to-end flow from DefaultLogger through LogSinkRouter to registered sinks.
+///
+[NotInParallel]
+public class LogSinkIntegrationTests
+{
+ private RecordingSink _sink = null!;
+
+ [Before(Test)]
+ public void SetUp()
+ {
+ TUnitLoggerFactory.Clear();
+ _sink = new RecordingSink();
+ TUnitLoggerFactory.AddSink(_sink);
+ }
+
+ [After(Test)]
+ public async Task TearDown()
+ {
+ await TUnitLoggerFactory.DisposeAllAsync();
+ }
+
+ [Test]
+ public async Task DefaultLogger_RoutesToRegisteredSink()
+ {
+ // Act
+ TestContext.Current!.GetDefaultLogger().LogInformation("test message");
+
+ // Assert
+ await Assert.That(_sink.Logs).Count().IsEqualTo(1);
+ await Assert.That(_sink.Logs[0].Message).Contains("test message");
+ }
+
+ [Test]
+ public async Task DefaultLogger_RoutesMultipleMessages()
+ {
+ // Act
+ var logger = TestContext.Current!.GetDefaultLogger();
+ logger.LogInformation("message 1");
+ logger.LogWarning("message 2");
+ logger.LogError("message 3");
+
+ // Assert
+ await Assert.That(_sink.Logs).Count().IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task DefaultLogger_PassesCorrectLogLevels()
+ {
+ // Act
+ var logger = TestContext.Current!.GetDefaultLogger();
+ logger.LogInformation("info message");
+ logger.LogWarning("warning message");
+ logger.LogError("error message");
+
+ // Assert - DefaultLogger converts to Information or Error based on level
+ // LogLevel.Information and LogLevel.Warning both become LogLevel.Information in WriteToOutput
+ // LogLevel.Error and above become LogLevel.Error
+ await Assert.That(_sink.Logs).Count().IsEqualTo(3);
+ await Assert.That(_sink.Logs[0].Level).IsEqualTo(LogLevel.Information);
+ await Assert.That(_sink.Logs[1].Level).IsEqualTo(LogLevel.Information);
+ await Assert.That(_sink.Logs[2].Level).IsEqualTo(LogLevel.Error);
+ }
+
+ [Test]
+ public async Task DefaultLogger_IncludesLogLevelInMessage()
+ {
+ // Act
+ TestContext.Current!.GetDefaultLogger().LogWarning("warning test");
+
+ // Assert
+ await Assert.That(_sink.Logs).Count().IsEqualTo(1);
+ await Assert.That(_sink.Logs[0].Message).Contains("Warning:");
+ await Assert.That(_sink.Logs[0].Message).Contains("warning test");
+ }
+
+ [Test]
+ public async Task DefaultLogger_PassesContextToSink()
+ {
+ // Act
+ TestContext.Current!.GetDefaultLogger().LogInformation("context test");
+
+ // Assert
+ await Assert.That(_sink.Logs).Count().IsEqualTo(1);
+ await Assert.That(_sink.Logs[0].Context).IsNotNull();
+ await Assert.That(_sink.Logs[0].Context).IsSameReferenceAs(TestContext.Current);
+ }
+
+ [Test]
+ public async Task DefaultLogger_AsyncLogging_RoutesToSink()
+ {
+ // Act
+ await TestContext.Current!.GetDefaultLogger().LogInformationAsync("async message");
+
+ // Assert
+ await Assert.That(_sink.Logs).Count().IsEqualTo(1);
+ await Assert.That(_sink.Logs[0].Message).Contains("async message");
+ }
+
+ [Test]
+ public async Task DefaultLogger_MultipleSinks_AllReceiveMessages()
+ {
+ // Arrange - add a second sink
+ var secondSink = new RecordingSink();
+ TUnitLoggerFactory.AddSink(secondSink);
+
+ // Act
+ TestContext.Current!.GetDefaultLogger().LogInformation("broadcast message");
+
+ // Assert
+ await Assert.That(_sink.Logs).Count().IsEqualTo(1);
+ await Assert.That(secondSink.Logs).Count().IsEqualTo(1);
+ await Assert.That(_sink.Logs[0].Message).Contains("broadcast message");
+ await Assert.That(secondSink.Logs[0].Message).Contains("broadcast message");
+ }
+
+ [Test]
+ public async Task DefaultLogger_DisabledSink_DoesNotReceiveMessages()
+ {
+ // Arrange - add a disabled sink
+ var disabledSink = new RecordingSink { Enabled = false };
+ TUnitLoggerFactory.AddSink(disabledSink);
+
+ // Act
+ TestContext.Current!.GetDefaultLogger().LogInformation("enabled only message");
+
+ // Assert
+ await Assert.That(_sink.Logs).Count().IsEqualTo(1); // Enabled sink receives it
+ await Assert.That(disabledSink.Logs).IsEmpty(); // Disabled sink does not
+ }
+
+ [Test]
+ public async Task DefaultLogger_LevelFilteredSink_OnlyReceivesMatchingLevels()
+ {
+ // Arrange - add a sink that only accepts Error or higher
+ var errorOnlySink = new LevelFilteredSink(LogLevel.Error);
+ TUnitLoggerFactory.AddSink(errorOnlySink);
+
+ // Act
+ var logger = TestContext.Current!.GetDefaultLogger();
+ logger.LogInformation("info message");
+ logger.LogWarning("warning message");
+ logger.LogError("error message");
+
+ // Assert
+ await Assert.That(_sink.Logs).Count().IsEqualTo(3); // Default sink receives all
+ await Assert.That(errorOnlySink.Logs).Count().IsEqualTo(1); // Filtered sink only receives error
+ await Assert.That(errorOnlySink.Logs[0].Message).Contains("error message");
+ }
+
+ #region Recording Sinks
+
+ private class RecordingSink : ILogSink
+ {
+ public List<(LogLevel Level, string Message, Exception? Exception, Context? Context)> Logs { get; } = [];
+ public bool Enabled { get; set; } = true;
+
+ public bool IsEnabled(LogLevel level) => Enabled;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ => Logs.Add((level, message, exception, context));
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Logs.Add((level, message, exception, context));
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private class LevelFilteredSink : ILogSink
+ {
+ private readonly LogLevel _minimumLevel;
+
+ public LevelFilteredSink(LogLevel minimumLevel)
+ {
+ _minimumLevel = minimumLevel;
+ }
+
+ public List<(LogLevel Level, string Message, Exception? Exception, Context? Context)> Logs { get; } = [];
+
+ public bool IsEnabled(LogLevel level) => level >= _minimumLevel;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ => Logs.Add((level, message, exception, context));
+
+ public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ Logs.Add((level, message, exception, context));
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ #endregion
+}
From 50ec94bd4bfa3250476ce7479a67ae413a9e41bb Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 21:15:42 +0000
Subject: [PATCH 15/25] chore: update public API surface for log sink system
---
...ibrary_Has_No_API_Changes.DotNet10_0.verified.txt | 12 ++++++++++++
...Library_Has_No_API_Changes.DotNet8_0.verified.txt | 12 ++++++++++++
...Library_Has_No_API_Changes.DotNet9_0.verified.txt | 12 ++++++++++++
...re_Library_Has_No_API_Changes.Net4_7.verified.txt | 12 ++++++++++++
4 files changed, 48 insertions(+)
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
index d14c5dd60b..54302afe11 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
@@ -2616,6 +2616,12 @@ namespace .Logging
protected virtual void WriteToOutput(string message, bool isError) { }
protected virtual . WriteToOutputAsync(string message, bool isError) { }
}
+ public interface ILogSink
+ {
+ bool IsEnabled(. level);
+ void Log(. level, string message, ? exception, .Context? context);
+ . LogAsync(. level, string message, ? exception, .Context? context);
+ }
public interface ILogger
{
bool IsEnabled(. logLevel);
@@ -2659,6 +2665,12 @@ namespace .Logging
public abstract void Log(. logLevel, TState state, ? exception, formatter);
public abstract . LogAsync(. logLevel, TState state, ? exception, formatter);
}
+ public static class TUnitLoggerFactory
+ {
+ public static void AddSink(. sink) { }
+ public static void AddSink()
+ where TSink : ., new () { }
+ }
}
namespace .Models
{
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
index 855998a426..285eebc8eb 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
@@ -2616,6 +2616,12 @@ namespace .Logging
protected virtual void WriteToOutput(string message, bool isError) { }
protected virtual . WriteToOutputAsync(string message, bool isError) { }
}
+ public interface ILogSink
+ {
+ bool IsEnabled(. level);
+ void Log(. level, string message, ? exception, .Context? context);
+ . LogAsync(. level, string message, ? exception, .Context? context);
+ }
public interface ILogger
{
bool IsEnabled(. logLevel);
@@ -2659,6 +2665,12 @@ namespace .Logging
public abstract void Log(. logLevel, TState state, ? exception, formatter);
public abstract . LogAsync(. logLevel, TState state, ? exception, formatter);
}
+ public static class TUnitLoggerFactory
+ {
+ public static void AddSink(. sink) { }
+ public static void AddSink()
+ where TSink : ., new () { }
+ }
}
namespace .Models
{
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
index 3f9990e990..27799ffefb 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
@@ -2616,6 +2616,12 @@ namespace .Logging
protected virtual void WriteToOutput(string message, bool isError) { }
protected virtual . WriteToOutputAsync(string message, bool isError) { }
}
+ public interface ILogSink
+ {
+ bool IsEnabled(. level);
+ void Log(. level, string message, ? exception, .Context? context);
+ . LogAsync(. level, string message, ? exception, .Context? context);
+ }
public interface ILogger
{
bool IsEnabled(. logLevel);
@@ -2659,6 +2665,12 @@ namespace .Logging
public abstract void Log(. logLevel, TState state, ? exception, formatter);
public abstract . LogAsync(. logLevel, TState state, ? exception, formatter);
}
+ public static class TUnitLoggerFactory
+ {
+ public static void AddSink(. sink) { }
+ public static void AddSink()
+ where TSink : ., new () { }
+ }
}
namespace .Models
{
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
index 03a16f9ba2..08b1e25e4b 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
@@ -2538,6 +2538,12 @@ namespace .Logging
protected virtual void WriteToOutput(string message, bool isError) { }
protected virtual . WriteToOutputAsync(string message, bool isError) { }
}
+ public interface ILogSink
+ {
+ bool IsEnabled(. level);
+ void Log(. level, string message, ? exception, .Context? context);
+ . LogAsync(. level, string message, ? exception, .Context? context);
+ }
public interface ILogger
{
bool IsEnabled(. logLevel);
@@ -2581,6 +2587,12 @@ namespace .Logging
public abstract void Log(. logLevel, TState state, ? exception, formatter);
public abstract . LogAsync(. logLevel, TState state, ? exception, formatter);
}
+ public static class TUnitLoggerFactory
+ {
+ public static void AddSink(. sink) { }
+ public static void AddSink()
+ where TSink : ., new () { }
+ }
}
namespace .Models
{
From 76aed6b51de03d8714e75591276f2bdddf4d8225 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 21:42:11 +0000
Subject: [PATCH 16/25] feat(engine): only register OutputDeviceLogSink for
non-console clients
Skip IDE streaming when running in console environment since console
output is already visible. Uses IClientInfo to detect console clients.
---
.../Framework/TUnitServiceProvider.cs | 23 +++++++++++++++++--
TUnit.TestProject/ConsoleTests.cs | 11 +++++++++
2 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs
index 9ce2f462c6..535aa60598 100644
--- a/TUnit.Engine/Framework/TUnitServiceProvider.cs
+++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs
@@ -26,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
@@ -104,8 +106,11 @@ public TUnitServiceProvider(IExtension extension,
loggerFactory.CreateLogger(),
logLevelProvider));
- // Register the built-in log sink for streaming logs to IDEs
- TUnitLoggerFactory.AddSink(new OutputDeviceLogSink(outputDevice, extension));
+ // Register the built-in log sink for streaming logs to IDEs (skip for console clients)
+ if (!IsConsoleClient(frameworkServiceProvider))
+ {
+ TUnitLoggerFactory.AddSink(new OutputDeviceLogSink(outputDevice, extension));
+ }
// Create initialization services using Lazy to break circular dependencies
// No more two-phase initialization with Initialize() calls
@@ -316,4 +321,18 @@ public async ValueTask DisposeAsync()
TestExtensions.ClearCaches();
}
+
+ private static bool IsConsoleClient(IServiceProvider serviceProvider)
+ {
+ try
+ {
+ var clientInfo = serviceProvider.GetClientInfo();
+ return clientInfo.Id.Contains("console", StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ // If we can't determine, default to console behavior (skip IDE streaming)
+ return true;
+ }
+ }
}
diff --git a/TUnit.TestProject/ConsoleTests.cs b/TUnit.TestProject/ConsoleTests.cs
index 520f9ac8ef..1da8684782 100644
--- a/TUnit.TestProject/ConsoleTests.cs
+++ b/TUnit.TestProject/ConsoleTests.cs
@@ -11,4 +11,15 @@ public async Task Write_Source_Gen_Information()
Console.WriteLine(TestContext.Current!.Metadata.TestDetails.MethodMetadata);
await Assert.That(TestContext.Current.GetStandardOutput()).Contains(TestContext.Current.Metadata.TestDetails.MethodMetadata.ToString()!);
}
+
+ [Test]
+ [Explicit]
+ public async Task StreamsToIde()
+ {
+ for (var i = 0; i < 10; i++)
+ {
+ Console.WriteLine(@$"{i}...");
+ await Task.Delay(TimeSpan.FromSeconds(1));
+ }
+ }
}
From b9dcc0c1ed939cd67c1e4f446c559f990a0244f5 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 21:58:03 +0000
Subject: [PATCH 17/25] fix: stream test output to IDEs via
TestNodeUpdateMessage
IDEs display test output from StandardOutputProperty on TestNodeUpdateMessage,
not from IOutputDevice.DisplayAsync. Changed approach to:
- Add OutputUpdate method to ITUnitMessageBus for real-time output updates
- Create IdeOutputLogSink that sends TestNodeUpdateMessage with current output
- Modify TestExtensions.ToTestNode to support output during InProgress state
- Remove unused OutputDeviceLogSink (IOutputDevice approach didn't work for IDEs)
This sends TestNodeUpdateMessage updates with InProgressTestNodeStateProperty
AND StandardOutputProperty, which IDEs pick up for real-time test output display.
---
TUnit.Core/ITUnitMessageBus.cs | 7 ++
TUnit.Engine/Extensions/TestExtensions.cs | 16 +++-
.../Framework/TUnitServiceProvider.cs | 13 ++--
TUnit.Engine/Logging/IdeOutputLogSink.cs | 43 +++++++++++
TUnit.Engine/Logging/OutputDeviceLogSink.cs | 77 -------------------
TUnit.Engine/TUnitMessageBus.cs | 11 +++
6 files changed, 84 insertions(+), 83 deletions(-)
create mode 100644 TUnit.Engine/Logging/IdeOutputLogSink.cs
delete mode 100644 TUnit.Engine/Logging/OutputDeviceLogSink.cs
diff --git a/TUnit.Core/ITUnitMessageBus.cs b/TUnit.Core/ITUnitMessageBus.cs
index f1f3629987..f711cd4567 100644
--- a/TUnit.Core/ITUnitMessageBus.cs
+++ b/TUnit.Core/ITUnitMessageBus.cs
@@ -51,4 +51,11 @@ internal interface ITUnitMessageBus
///
/// The artifact.
ValueTask SessionArtifact(Artifact artifact);
+
+ ///
+ /// Sends an output update for a test that's currently in progress.
+ /// This allows IDEs to display test output in real-time.
+ ///
+ /// The test context.
+ ValueTask OutputUpdate(TestContext testContext);
}
diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs
index 00e50a27f7..b841fd6989 100644
--- a/TUnit.Engine/Extensions/TestExtensions.cs
+++ b/TUnit.Engine/Extensions/TestExtensions.cs
@@ -112,7 +112,20 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext
}, testContext);
}
+ ///
+ /// Creates a test node with output for real-time streaming (used during InProgress state).
+ ///
+ internal static TestNode ToTestNodeWithOutput(this TestContext testContext, TestNodeStateProperty stateProperty)
+ {
+ return ToTestNodeInternal(testContext, stateProperty, includeOutput: true);
+ }
+
internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateProperty stateProperty)
+ {
+ return ToTestNodeInternal(testContext, stateProperty, includeOutput: false);
+ }
+
+ private static TestNode ToTestNodeInternal(TestContext testContext, TestNodeStateProperty stateProperty, bool includeOutput)
{
var testDetails = testContext.Metadata.TestDetails ?? throw new ArgumentNullException(nameof(testContext.Metadata.TestDetails));
@@ -152,7 +165,8 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP
string? output = null;
string? error = null;
- if (isFinalState)
+ // Include output if it's final state OR if explicitly requested for real-time streaming
+ if (isFinalState || includeOutput)
{
output = testContext.GetStandardOutput();
error = testContext.GetErrorOutput();
diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs
index 535aa60598..9b1fb49882 100644
--- a/TUnit.Engine/Framework/TUnitServiceProvider.cs
+++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs
@@ -106,11 +106,8 @@ public TUnitServiceProvider(IExtension extension,
loggerFactory.CreateLogger(),
logLevelProvider));
- // Register the built-in log sink for streaming logs to IDEs (skip for console clients)
- if (!IsConsoleClient(frameworkServiceProvider))
- {
- TUnitLoggerFactory.AddSink(new OutputDeviceLogSink(outputDevice, extension));
- }
+ // Defer log sink registration until after MessageBus is created
+ var isIdeClient = !IsConsoleClient(frameworkServiceProvider);
// Create initialization services using Lazy to break circular dependencies
// No more two-phase initialization with Initialize() calls
@@ -145,6 +142,12 @@ public TUnitServiceProvider(IExtension extension,
frameworkServiceProvider,
context));
+ // Register the built-in log sink for streaming test output to IDEs in real-time
+ if (isIdeClient)
+ {
+ TUnitLoggerFactory.AddSink(new IdeOutputLogSink(MessageBus));
+ }
+
CancellationToken = Register(new EngineCancellationToken());
EventReceiverOrchestrator = Register(new EventReceiverOrchestrator(Logger));
diff --git a/TUnit.Engine/Logging/IdeOutputLogSink.cs b/TUnit.Engine/Logging/IdeOutputLogSink.cs
new file mode 100644
index 0000000000..8f1a349b29
--- /dev/null
+++ b/TUnit.Engine/Logging/IdeOutputLogSink.cs
@@ -0,0 +1,43 @@
+using TUnit.Core;
+using TUnit.Core.Logging;
+
+namespace TUnit.Engine.Logging;
+
+///
+/// A log sink that streams test output to IDEs in real-time by sending
+/// TestNodeUpdateMessage updates via the message bus.
+///
+internal class IdeOutputLogSink : ILogSink
+{
+ private readonly TUnitMessageBus _messageBus;
+
+ public IdeOutputLogSink(TUnitMessageBus messageBus)
+ {
+ _messageBus = messageBus;
+ }
+
+ public bool IsEnabled(LogLevel level) => level >= LogLevel.Information;
+
+ public void Log(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ // Fire and forget - the async call will complete in background
+ _ = LogAsync(level, message, exception, context);
+ }
+
+ public async ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
+ {
+ if (!IsEnabled(level))
+ {
+ return;
+ }
+
+ // Only stream output for test contexts (not assembly/class hooks etc.)
+ if (context is not TestContext testContext)
+ {
+ return;
+ }
+
+ // Send an output update to the IDE
+ await _messageBus.OutputUpdate(testContext).ConfigureAwait(false);
+ }
+}
diff --git a/TUnit.Engine/Logging/OutputDeviceLogSink.cs b/TUnit.Engine/Logging/OutputDeviceLogSink.cs
deleted file mode 100644
index eb5db6faa2..0000000000
--- a/TUnit.Engine/Logging/OutputDeviceLogSink.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using Microsoft.Testing.Platform.Extensions;
-using Microsoft.Testing.Platform.Extensions.OutputDevice;
-using Microsoft.Testing.Platform.OutputDevice;
-using TUnit.Core;
-using TUnit.Core.Logging;
-
-namespace TUnit.Engine.Logging;
-
-///
-/// A built-in log sink that streams log messages to IDEs (Rider, VS, etc.)
-/// via Microsoft Testing Platform's IOutputDevice.
-///
-internal class OutputDeviceLogSink : ILogSink, IOutputDeviceDataProducer
-{
- private readonly IOutputDevice _outputDevice;
- private readonly LogLevel _minLevel;
- private readonly IExtension _extension;
-
- public OutputDeviceLogSink(IOutputDevice outputDevice, IExtension extension, LogLevel minLevel = LogLevel.Information)
- {
- _outputDevice = outputDevice;
- _extension = extension;
- _minLevel = minLevel;
- }
-
- public string Uid => _extension.Uid;
- public string Version => _extension.Version;
- public string DisplayName => _extension.DisplayName;
- public string Description => _extension.Description;
-
- public Task IsEnabledAsync() => Task.FromResult(true);
-
- public bool IsEnabled(LogLevel level) => level >= _minLevel;
-
- public void Log(LogLevel level, string message, Exception? exception, Context? context)
- {
- // Fire and forget - IOutputDevice is async-only
- _ = LogAsync(level, message, exception, context);
- }
-
- public async ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
- {
- if (!IsEnabled(level))
- {
- return;
- }
-
- var formattedMessage = FormatMessage(message, exception);
- var color = GetConsoleColor(level);
-
- await _outputDevice.DisplayAsync(
- this,
- new FormattedTextOutputDeviceData(formattedMessage)
- {
- ForegroundColor = new SystemConsoleColor { ConsoleColor = color }
- },
- CancellationToken.None).ConfigureAwait(false);
- }
-
- private static string FormatMessage(string message, Exception? exception)
- {
- if (exception is null)
- {
- return message;
- }
-
- return $"{message}{Environment.NewLine}{exception}";
- }
-
- private static ConsoleColor GetConsoleColor(LogLevel level) => level switch
- {
- LogLevel.Error => ConsoleColor.Red,
- LogLevel.Warning => ConsoleColor.Yellow,
- LogLevel.Debug => ConsoleColor.Gray,
- _ => ConsoleColor.White
- };
-}
diff --git a/TUnit.Engine/TUnitMessageBus.cs b/TUnit.Engine/TUnitMessageBus.cs
index 2195b7ae06..7ea48ddb2c 100644
--- a/TUnit.Engine/TUnitMessageBus.cs
+++ b/TUnit.Engine/TUnitMessageBus.cs
@@ -131,6 +131,17 @@ public ValueTask SessionArtifact(Artifact artifact)
));
}
+ public ValueTask OutputUpdate(TestContext testContext)
+ {
+ // Send an InProgress update with the current output to stream to IDEs
+ var testNode = testContext.ToTestNodeWithOutput(InProgressTestNodeStateProperty.CachedInstance);
+
+ return new ValueTask(context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(
+ sessionUid: _sessionSessionUid,
+ testNode: testNode
+ )));
+ }
+
private static TestNodeStateProperty GetFailureStateProperty(TestContext testContext, Exception e, TimeSpan duration)
{
if (testContext.Metadata.TestDetails.Timeout != null
From 6d3a5c4c643c095971281af7d88abda2795b5cf0 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 22:11:37 +0000
Subject: [PATCH 18/25] fix: send only new output instead of accumulated output
---
TUnit.Core/ITUnitMessageBus.cs | 3 ++-
TUnit.Engine/Extensions/TestExtensions.cs | 19 ++++++++++++-------
TUnit.Engine/Logging/IdeOutputLogSink.cs | 7 +++----
TUnit.Engine/TUnitMessageBus.cs | 6 +++---
4 files changed, 20 insertions(+), 15 deletions(-)
diff --git a/TUnit.Core/ITUnitMessageBus.cs b/TUnit.Core/ITUnitMessageBus.cs
index f711cd4567..1007b98fbf 100644
--- a/TUnit.Core/ITUnitMessageBus.cs
+++ b/TUnit.Core/ITUnitMessageBus.cs
@@ -57,5 +57,6 @@ internal interface ITUnitMessageBus
/// This allows IDEs to display test output in real-time.
///
/// The test context.
- ValueTask OutputUpdate(TestContext testContext);
+ /// The new output to send.
+ ValueTask OutputUpdate(TestContext testContext, string output);
}
diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs
index b841fd6989..9930efaac8 100644
--- a/TUnit.Engine/Extensions/TestExtensions.cs
+++ b/TUnit.Engine/Extensions/TestExtensions.cs
@@ -113,19 +113,19 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext
}
///
- /// Creates a test node with output for real-time streaming (used during InProgress state).
+ /// Creates a test node with specific output for real-time streaming (used during InProgress state).
///
- internal static TestNode ToTestNodeWithOutput(this TestContext testContext, TestNodeStateProperty stateProperty)
+ internal static TestNode ToTestNodeWithOutput(this TestContext testContext, TestNodeStateProperty stateProperty, string output)
{
- return ToTestNodeInternal(testContext, stateProperty, includeOutput: true);
+ return ToTestNodeInternal(testContext, stateProperty, streamingOutput: output);
}
internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateProperty stateProperty)
{
- return ToTestNodeInternal(testContext, stateProperty, includeOutput: false);
+ return ToTestNodeInternal(testContext, stateProperty, streamingOutput: null);
}
- private static TestNode ToTestNodeInternal(TestContext testContext, TestNodeStateProperty stateProperty, bool includeOutput)
+ private static TestNode ToTestNodeInternal(TestContext testContext, TestNodeStateProperty stateProperty, string? streamingOutput)
{
var testDetails = testContext.Metadata.TestDetails ?? throw new ArgumentNullException(nameof(testContext.Metadata.TestDetails));
@@ -165,8 +165,13 @@ private static TestNode ToTestNodeInternal(TestContext testContext, TestNodeStat
string? output = null;
string? error = null;
- // Include output if it's final state OR if explicitly requested for real-time streaming
- if (isFinalState || includeOutput)
+ // For streaming output (real-time), use the provided output directly
+ // For final state, get the accumulated output from context
+ if (streamingOutput is not null)
+ {
+ properties.Add(new StandardOutputProperty(streamingOutput));
+ }
+ else if (isFinalState)
{
output = testContext.GetStandardOutput();
error = testContext.GetErrorOutput();
diff --git a/TUnit.Engine/Logging/IdeOutputLogSink.cs b/TUnit.Engine/Logging/IdeOutputLogSink.cs
index 8f1a349b29..3254b253c9 100644
--- a/TUnit.Engine/Logging/IdeOutputLogSink.cs
+++ b/TUnit.Engine/Logging/IdeOutputLogSink.cs
@@ -20,13 +20,12 @@ public IdeOutputLogSink(TUnitMessageBus messageBus)
public void Log(LogLevel level, string message, Exception? exception, Context? context)
{
- // Fire and forget - the async call will complete in background
_ = LogAsync(level, message, exception, context);
}
public async ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
{
- if (!IsEnabled(level))
+ if (!IsEnabled(level) || string.IsNullOrEmpty(message))
{
return;
}
@@ -37,7 +36,7 @@ public async ValueTask LogAsync(LogLevel level, string message, Exception? excep
return;
}
- // Send an output update to the IDE
- await _messageBus.OutputUpdate(testContext).ConfigureAwait(false);
+ // Send just the new output to the IDE
+ await _messageBus.OutputUpdate(testContext, message).ConfigureAwait(false);
}
}
diff --git a/TUnit.Engine/TUnitMessageBus.cs b/TUnit.Engine/TUnitMessageBus.cs
index 7ea48ddb2c..cf69211738 100644
--- a/TUnit.Engine/TUnitMessageBus.cs
+++ b/TUnit.Engine/TUnitMessageBus.cs
@@ -131,10 +131,10 @@ public ValueTask SessionArtifact(Artifact artifact)
));
}
- public ValueTask OutputUpdate(TestContext testContext)
+ public ValueTask OutputUpdate(TestContext testContext, string output)
{
- // Send an InProgress update with the current output to stream to IDEs
- var testNode = testContext.ToTestNodeWithOutput(InProgressTestNodeStateProperty.CachedInstance);
+ // Send an InProgress update with just the new output to stream to IDEs
+ var testNode = testContext.ToTestNodeWithOutput(InProgressTestNodeStateProperty.CachedInstance, output);
return new ValueTask(context.MessageBus.PublishAsync(this, new TestNodeUpdateMessage(
sessionUid: _sessionSessionUid,
From bfb510d893577b17e368cf90328840d1ee8bf7cc Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 22:17:31 +0000
Subject: [PATCH 19/25] refactor: rename IdeOutputLogSink to RealTimeOutputSink
- Renamed to better reflect its purpose (streaming output in real-time)
- Enabled for both IDE clients AND --output Detailed mode
- IDE clients hide console output to avoid duplication (stream via TestNodeUpdateMessage only)
- Console clients with --output Detailed get both console output and TestNodeUpdateMessage
---
TUnit.Engine/Extensions/TestExtensions.cs | 9 ++-------
TUnit.Engine/Framework/TUnitServiceProvider.cs | 6 +++---
.../{IdeOutputLogSink.cs => RealTimeOutputSink.cs} | 9 +++++----
TUnit.Engine/Services/VerbosityService.cs | 12 ++++++++++--
4 files changed, 20 insertions(+), 16 deletions(-)
rename TUnit.Engine/Logging/{IdeOutputLogSink.cs => RealTimeOutputSink.cs} (76%)
diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs
index 9930efaac8..c9b1d81e89 100644
--- a/TUnit.Engine/Extensions/TestExtensions.cs
+++ b/TUnit.Engine/Extensions/TestExtensions.cs
@@ -165,13 +165,8 @@ private static TestNode ToTestNodeInternal(TestContext testContext, TestNodeStat
string? output = null;
string? error = null;
- // For streaming output (real-time), use the provided output directly
- // For final state, get the accumulated output from context
- if (streamingOutput is not null)
- {
- properties.Add(new StandardOutputProperty(streamingOutput));
- }
- else if (isFinalState)
+ // Include output for streaming (real-time) or final state
+ if (streamingOutput is not null || isFinalState)
{
output = testContext.GetStandardOutput();
error = testContext.GetErrorOutput();
diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs
index 9b1fb49882..22df93e678 100644
--- a/TUnit.Engine/Framework/TUnitServiceProvider.cs
+++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs
@@ -142,10 +142,10 @@ public TUnitServiceProvider(IExtension extension,
frameworkServiceProvider,
context));
- // Register the built-in log sink for streaming test output to IDEs in real-time
- if (isIdeClient)
+ // Register the real-time output sink for IDEs and --output Detailed mode
+ if (isIdeClient || VerbosityService.IsDetailedOutput)
{
- TUnitLoggerFactory.AddSink(new IdeOutputLogSink(MessageBus));
+ TUnitLoggerFactory.AddSink(new RealTimeOutputSink(MessageBus));
}
CancellationToken = Register(new EngineCancellationToken());
diff --git a/TUnit.Engine/Logging/IdeOutputLogSink.cs b/TUnit.Engine/Logging/RealTimeOutputSink.cs
similarity index 76%
rename from TUnit.Engine/Logging/IdeOutputLogSink.cs
rename to TUnit.Engine/Logging/RealTimeOutputSink.cs
index 3254b253c9..94e0820ba6 100644
--- a/TUnit.Engine/Logging/IdeOutputLogSink.cs
+++ b/TUnit.Engine/Logging/RealTimeOutputSink.cs
@@ -4,14 +4,15 @@
namespace TUnit.Engine.Logging;
///
-/// A log sink that streams test output to IDEs in real-time by sending
+/// A log sink that streams test output in real-time by sending
/// TestNodeUpdateMessage updates via the message bus.
+/// Enabled for IDE clients and when --output Detailed is used.
///
-internal class IdeOutputLogSink : ILogSink
+internal class RealTimeOutputSink : ILogSink
{
private readonly TUnitMessageBus _messageBus;
- public IdeOutputLogSink(TUnitMessageBus messageBus)
+ public RealTimeOutputSink(TUnitMessageBus messageBus)
{
_messageBus = messageBus;
}
@@ -36,7 +37,7 @@ public async ValueTask LogAsync(LogLevel level, string message, Exception? excep
return;
}
- // Send just the new output to the IDE
+ // Send the output in real-time via TestNodeUpdateMessage
await _messageBus.OutputUpdate(testContext, message).ConfigureAwait(false);
}
}
diff --git a/TUnit.Engine/Services/VerbosityService.cs b/TUnit.Engine/Services/VerbosityService.cs
index 0dc093289d..9cd20393a9 100644
--- a/TUnit.Engine/Services/VerbosityService.cs
+++ b/TUnit.Engine/Services/VerbosityService.cs
@@ -19,8 +19,14 @@ public VerbosityService(ICommandLineOptions commandLineOptions, IServiceProvider
{
_isDetailedOutput = GetOutputLevel(commandLineOptions, serviceProvider);
_logLevel = GetLogLevel(commandLineOptions);
+ IsIdeClient = !IsConsoleEnvironment(serviceProvider);
}
+ ///
+ /// Whether running in an IDE (Rider, VS, etc.) vs console.
+ ///
+ public bool IsIdeClient { get; }
+
///
/// Whether to show detailed stack traces (enabled with Debug/Trace log level)
///
@@ -32,9 +38,11 @@ public VerbosityService(ICommandLineOptions commandLineOptions, IServiceProvider
public bool IsDetailedOutput => _isDetailedOutput;
///
- /// Whether to hide real-time test output (hidden with --output Normal, unless log level is Debug/Trace)
+ /// Whether to hide real-time test output from the console.
+ /// For IDE clients, we hide console output because we stream via TestNodeUpdateMessage instead.
+ /// For console clients, we hide if --output Normal and log level is not Debug/Trace.
///
- public bool HideTestOutput => !_isDetailedOutput && _logLevel > LogLevel.Debug;
+ public bool HideTestOutput => IsIdeClient || (!_isDetailedOutput && _logLevel > LogLevel.Debug);
///
/// Creates a summary of current output and diagnostic settings
From 957da007d013208a5d84484d907f2afa22f39063 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sat, 17 Jan 2026 22:24:18 +0000
Subject: [PATCH 20/25] refactor: clean up and improve logging architecture
- Fix: Remove static modifier from LogLevelProvider._logLevel to prevent
session pollution across test runs (critical bug)
- Rename Engine.NullLogger to MtpNullLogger to distinguish from
Core.NullLogger (different interfaces)
- Remove unused AsyncConsoleWriter class (dead code)
- Remove dead ProcessFlushQueue() method in BufferedTextWriter
- Improve comments in DefaultLogger to clarify output flow:
- Context buffered output for test results
- Log sinks for real-time streaming (IDE, Seq, etc.)
- Improve VerbosityService documentation and fix incomplete
CreateVerbositySummary() method
---
TUnit.Core/Logging/DefaultLogger.cs | 8 +-
TUnit.Engine/Logging/AsyncConsoleWriter.cs | 395 ------------------
TUnit.Engine/Logging/BufferedTextWriter.cs | 9 -
.../{NullLogger.cs => MtpNullLogger.cs} | 5 +-
TUnit.Engine/NullLoggerFactory.cs | 4 +-
TUnit.Engine/Services/LogLevelProvider.cs | 16 +-
TUnit.Engine/Services/VerbosityService.cs | 11 +-
7 files changed, 28 insertions(+), 420 deletions(-)
delete mode 100644 TUnit.Engine/Logging/AsyncConsoleWriter.cs
rename TUnit.Engine/{NullLogger.cs => MtpNullLogger.cs} (73%)
diff --git a/TUnit.Core/Logging/DefaultLogger.cs b/TUnit.Core/Logging/DefaultLogger.cs
index 6f530c626a..a09970e9d9 100644
--- a/TUnit.Core/Logging/DefaultLogger.cs
+++ b/TUnit.Core/Logging/DefaultLogger.cs
@@ -126,7 +126,7 @@ protected virtual void WriteToOutput(string message, bool isError)
{
var level = isError ? LogLevel.Error : LogLevel.Information;
- // Historical capture
+ // Write to context's buffered output for test results
if (isError)
{
context.ErrorOutputWriter.WriteLine(message);
@@ -136,7 +136,7 @@ protected virtual void WriteToOutput(string message, bool isError)
context.OutputWriter.WriteLine(message);
}
- // Real-time streaming to sinks
+ // Stream to registered log sinks (real-time output to IDEs, Seq, etc.)
LogSinkRouter.RouteToSinks(level, message, null, context);
}
@@ -151,7 +151,7 @@ protected virtual async ValueTask WriteToOutputAsync(string message, bool isErro
{
var level = isError ? LogLevel.Error : LogLevel.Information;
- // Historical capture
+ // Write to context's buffered output for test results
if (isError)
{
await context.ErrorOutputWriter.WriteLineAsync(message);
@@ -161,7 +161,7 @@ protected virtual async ValueTask WriteToOutputAsync(string message, bool isErro
await context.OutputWriter.WriteLineAsync(message);
}
- // Real-time streaming to sinks
+ // Stream to registered log sinks (real-time output to IDEs, Seq, etc.)
await LogSinkRouter.RouteToSinksAsync(level, message, null, context);
}
}
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
index 230c3d9f7f..4958d08bd2 100644
--- a/TUnit.Engine/Logging/BufferedTextWriter.cs
+++ b/TUnit.Engine/Logging/BufferedTextWriter.cs
@@ -397,15 +397,6 @@ private void FlushAllThreadBuffers()
}
}
- private void ProcessFlushQueue()
- {
- // Process all queued content
- while (_flushQueue.TryDequeue(out var content))
- {
- _target.Write(content);
- }
- }
-
private void AutoFlush(object? state)
{
if (_disposed)
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