Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
18a954b
docs: add log streaming plugin system design
thomhurst Jan 17, 2026
f7e119c
fix: correct .worktrees gitignore pattern
thomhurst Jan 17, 2026
06a6b16
docs: add log streaming implementation plan
thomhurst Jan 17, 2026
77cb4ef
feat(logging): add ILogSink interface for log destinations
thomhurst Jan 17, 2026
0063f09
feat(logging): add TUnitLoggerFactory for sink registration
thomhurst Jan 17, 2026
c2957c9
feat(logging): add LogSinkRouter helper for sink dispatch
thomhurst Jan 17, 2026
5c203ef
feat(logging): route DefaultLogger output to registered sinks
thomhurst Jan 17, 2026
03b7c31
feat(logging): route Console.WriteLine to registered sinks
thomhurst Jan 17, 2026
f0f489e
feat(engine): add OutputDeviceLogSink for real-time IDE streaming
thomhurst Jan 17, 2026
10da366
feat(engine): register OutputDeviceLogSink at session start
thomhurst Jan 17, 2026
8c3b86b
feat(engine): dispose log sinks at session end
thomhurst Jan 17, 2026
30818c0
test: add unit tests for TUnitLoggerFactory
thomhurst Jan 17, 2026
0ce4571
test: add unit tests for LogSinkRouter
thomhurst Jan 17, 2026
319912b
test: add integration tests for log sink system
thomhurst Jan 17, 2026
50ec94b
chore: update public API surface for log sink system
thomhurst Jan 17, 2026
76aed6b
feat(engine): only register OutputDeviceLogSink for non-console clients
thomhurst Jan 17, 2026
b9dcc0c
fix: stream test output to IDEs via TestNodeUpdateMessage
thomhurst Jan 17, 2026
6d3a5c4
fix: send only new output instead of accumulated output
thomhurst Jan 17, 2026
bfb510d
refactor: rename IdeOutputLogSink to RealTimeOutputSink
thomhurst Jan 17, 2026
957da00
refactor: clean up and improve logging architecture
thomhurst Jan 17, 2026
34ee41e
refactor: simplify logging to sink-based architecture with documentation
thomhurst Jan 17, 2026
d7cdf87
fix: eliminate IDE output duplication by reading from OutputWriter
thomhurst Jan 17, 2026
73d0150
fix: use streaming output chunk instead of full accumulated output
thomhurst Jan 17, 2026
2ab6c78
fix: exclude final output for IDE clients to prevent duplication
thomhurst Jan 17, 2026
5d30710
refactor: remove IDE streaming logic to focus PR on log sinks extensi…
thomhurst Jan 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -438,4 +438,4 @@ doc/plans/
*.nettrace

# Git worktrees
.worktrees/.worktrees/
.worktrees/
34 changes: 14 additions & 20 deletions TUnit.Core/Logging/DefaultLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,13 @@ protected virtual string GenerateMessage(string message, Exception? exception, L
/// <param name="isError">True if this is an error-level message.</param>
protected virtual void WriteToOutput(string message, bool isError)
{
if (isError)
{
context.ErrorOutputWriter.WriteLine(message);
GlobalContext.Current.OriginalConsoleError.WriteLine(message);
}
else
{
context.OutputWriter.WriteLine(message);
GlobalContext.Current.OriginalConsoleOut.WriteLine(message);
}
var level = isError ? LogLevel.Error : LogLevel.Information;

// Route to registered log sinks - they handle output destinations:
// - TestOutputSink: accumulates to context for test results
// - ConsoleOutputSink: writes to console (if --output Detailed)
// - RealTimeOutputSink: streams to IDEs
LogSinkRouter.RouteToSinks(level, message, null, context);
}

/// <summary>
Expand All @@ -145,15 +142,12 @@ protected virtual void WriteToOutput(string message, bool isError)
/// <returns>A task representing the async operation.</returns>
protected virtual async ValueTask WriteToOutputAsync(string message, bool isError)
{
if (isError)
{
await context.ErrorOutputWriter.WriteLineAsync(message);
await GlobalContext.Current.OriginalConsoleError.WriteLineAsync(message);
}
else
{
await context.OutputWriter.WriteLineAsync(message);
await GlobalContext.Current.OriginalConsoleOut.WriteLineAsync(message);
}
var level = isError ? LogLevel.Error : LogLevel.Information;

// Route to registered log sinks - they handle output destinations:
// - TestOutputSink: accumulates to context for test results
// - ConsoleOutputSink: writes to console (if --output Detailed)
// - RealTimeOutputSink: streams to IDEs
await LogSinkRouter.RouteToSinksAsync(level, message, null, context);
}
}
108 changes: 108 additions & 0 deletions TUnit.Core/Logging/ILogSink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
namespace TUnit.Core.Logging;

/// <summary>
/// Represents a destination for log messages. Implement this interface
/// to create custom log sinks that receive output from tests.
/// </summary>
/// <remarks>
/// <para>
/// Log sinks receive all output from:
/// <list type="bullet">
/// <item><description><c>Console.WriteLine()</c> calls during test execution</description></item>
/// <item><description><c>Console.Error.WriteLine()</c> calls (with <see cref="LogLevel.Error"/>)</description></item>
/// <item><description>TUnit logger output via <c>TestContext.Current.GetDefaultLogger()</c></description></item>
/// </list>
/// </para>
/// <para>
/// Register your sink in a <c>[Before(Assembly)]</c> hook or before tests run using
/// <see cref="TUnitLoggerFactory.AddSink(ILogSink)"/>.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Example: File logging sink
/// public class FileLogSink : ILogSink, IAsyncDisposable
/// {
/// private readonly StreamWriter _writer;
///
/// public FileLogSink(string path)
/// {
/// _writer = new StreamWriter(path, append: true);
/// }
///
/// public bool IsEnabled(LogLevel level) => level >= LogLevel.Information;
///
/// public void Log(LogLevel level, string message, Exception? exception, Context? context)
/// {
/// var testName = context is TestContext tc ? tc.TestDetails.TestName : "Unknown";
/// _writer.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{level}] [{testName}] {message}");
/// if (exception != null)
/// _writer.WriteLine(exception.ToString());
/// }
///
/// public ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context)
/// {
/// Log(level, message, exception, context);
/// return ValueTask.CompletedTask;
/// }
///
/// public async ValueTask DisposeAsync()
/// {
/// await _writer.FlushAsync();
/// await _writer.DisposeAsync();
/// }
/// }
///
/// // Register in assembly hook:
/// [Before(Assembly)]
/// public static void SetupLogging()
/// {
/// TUnitLoggerFactory.AddSink(new FileLogSink("test-output.log"));
/// }
/// </code>
/// </example>
public interface ILogSink
{
/// <summary>
/// Determines if this sink should receive messages at the specified level.
/// Return <c>false</c> to skip processing for performance.
/// </summary>
/// <param name="level">The log level to check.</param>
/// <returns><c>true</c> if messages at this level should be logged; otherwise <c>false</c>.</returns>
bool IsEnabled(LogLevel level);

/// <summary>
/// Synchronously logs a message to this sink.
/// </summary>
/// <param name="level">The log level (Information, Warning, Error, etc.).</param>
/// <param name="message">The formatted message to log.</param>
/// <param name="exception">Optional exception associated with this log entry.</param>
/// <param name="context">
/// The current execution context, which may be:
/// <list type="bullet">
/// <item><description><see cref="TestContext"/> - during test execution</description></item>
/// <item><description><see cref="ClassHookContext"/> - during class hooks</description></item>
/// <item><description><see cref="AssemblyHookContext"/> - during assembly hooks</description></item>
/// <item><description><c>null</c> - if outside test execution</description></item>
/// </list>
/// </param>
void Log(LogLevel level, string message, Exception? exception, Context? context);

/// <summary>
/// Asynchronously logs a message to this sink.
/// </summary>
/// <param name="level">The log level (Information, Warning, Error, etc.).</param>
/// <param name="message">The formatted message to log.</param>
/// <param name="exception">Optional exception associated with this log entry.</param>
/// <param name="context">
/// The current execution context, which may be:
/// <list type="bullet">
/// <item><description><see cref="TestContext"/> - during test execution</description></item>
/// <item><description><see cref="ClassHookContext"/> - during class hooks</description></item>
/// <item><description><see cref="AssemblyHookContext"/> - during assembly hooks</description></item>
/// <item><description><c>null</c> - if outside test execution</description></item>
/// </list>
/// </param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
ValueTask LogAsync(LogLevel level, string message, Exception? exception, Context? context);
}
63 changes: 63 additions & 0 deletions TUnit.Core/Logging/LogSinkRouter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace TUnit.Core.Logging;

/// <summary>
/// Internal helper for routing log messages to all registered sinks.
/// </summary>
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);
}
}
}
}
Loading
Loading