diff --git a/tests/OpenClaw.Shared.Tests/AsyncEventHandlerGuardTests.cs b/tests/OpenClaw.Shared.Tests/AsyncEventHandlerGuardTests.cs new file mode 100644 index 00000000..ada53b02 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/AsyncEventHandlerGuardTests.cs @@ -0,0 +1,215 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Shared; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +/// +/// Tests for . +/// +/// The guard is a fault boundary: fire-and-forget async work must not +/// propagate exceptions to the caller's synchronisation context. These +/// tests verify that all exit paths (success, cancellation, unexpected +/// exception) are handled correctly and that the optional callbacks fire. +/// +public class AsyncEventHandlerGuardTests +{ + // ─── Run: null guard ────────────────────────────────────────────────────── + + [Fact] + public void Run_NullWork_ThrowsArgumentNullException() + { + Assert.Throws(() => + AsyncEventHandlerGuard.Run(null!)); + } + + // ─── Run: happy path ───────────────────────────────────────────────────── + + [Fact] + public async Task Run_SuccessfulWork_CompletesWithoutError() + { + var completed = new TaskCompletionSource(); + + AsyncEventHandlerGuard.Run(async () => + { + await Task.Yield(); + completed.SetResult(true); + }); + + var result = await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(result); + } + + // ─── Run: OperationCanceledException is silently swallowed ─────────────── + + [Fact] + public async Task Run_CancelledException_DoesNotInvokeOnError() + { + var onErrorInvoked = false; + var finished = new TaskCompletionSource(); + + AsyncEventHandlerGuard.Run( + async () => + { + await Task.Yield(); + finished.SetResult(true); + throw new OperationCanceledException("test cancel"); + }, + onError: _ => { onErrorInvoked = true; }); + + await finished.Task.WaitAsync(TimeSpan.FromSeconds(5)); + // Brief yield to let RunCoreAsync complete its catch block. + await Task.Delay(50); + + Assert.False(onErrorInvoked, "OperationCanceledException should be swallowed, not forwarded to onError"); + } + + [Fact] + public async Task Run_CancelledException_LogsDebugMessage() + { + string? debugMessage = null; + var finished = new TaskCompletionSource(); + var logger = new CapturingLogger(debug: m => debugMessage = m); + + AsyncEventHandlerGuard.Run( + async () => + { + await Task.Yield(); + finished.SetResult(true); + throw new OperationCanceledException("user cancel"); + }, + logger: logger); + + await finished.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await Task.Delay(50); + + Assert.NotNull(debugMessage); + Assert.Contains("user cancel", debugMessage, StringComparison.OrdinalIgnoreCase); + } + + // ─── Run: unexpected exception calls onError ────────────────────────────── + + [Fact] + public async Task Run_UnexpectedException_InvokesOnError() + { + Exception? captured = null; + var errorSignal = new TaskCompletionSource(); + + AsyncEventHandlerGuard.Run( + async () => + { + await Task.Yield(); + throw new InvalidOperationException("boom"); + }, + onError: ex => + { + captured = ex; + errorSignal.TrySetResult(true); + }); + + await errorSignal.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.IsType(captured); + Assert.Equal("boom", captured!.Message); + } + + [Fact] + public async Task Run_UnexpectedException_LogsErrorWithException() + { + string? loggedError = null; + Exception? loggedException = null; + var errorSignal = new TaskCompletionSource(); + var logger = new CapturingLogger(error: (m, ex) => + { + loggedError = m; + loggedException = ex; + errorSignal.TrySetResult(true); + }); + + AsyncEventHandlerGuard.Run( + async () => + { + await Task.Yield(); + throw new InvalidOperationException("something bad"); + }, + logger: logger); + + await errorSignal.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(loggedError); + Assert.Contains("failed", loggedError, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(loggedException); + Assert.Equal("something bad", loggedException!.Message); + } + + // ─── Run: operationName shows in logged messages ────────────────────────── + + [Fact] + public async Task Run_OperationName_AppearsInLogMessages() + { + string? loggedError = null; + var errorSignal = new TaskCompletionSource(); + var logger = new CapturingLogger(error: (m, _) => + { + loggedError = m; + errorSignal.TrySetResult(true); + }); + + AsyncEventHandlerGuard.Run( + async () => + { + await Task.Yield(); + throw new Exception("oops"); + }, + logger: logger, + operationName: "MySpecialOperation"); + + await errorSignal.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(loggedError); + Assert.Contains("MySpecialOperation", loggedError, StringComparison.OrdinalIgnoreCase); + } + + // ─── Run: no logger / no onError — must not throw ───────────────────────── + + [Fact] + public async Task Run_NoCallbacks_ExceptionIsSwallowedCleanly() + { + var finished = new TaskCompletionSource(); + + AsyncEventHandlerGuard.Run(async () => + { + await Task.Yield(); + finished.SetResult(true); + throw new Exception("silent failure"); + }); + + await finished.Task.WaitAsync(TimeSpan.FromSeconds(5)); + // Give the async continuation time to reach the catch block. + await Task.Delay(50); + // If we got here the unobserved exception did not tear down the process. + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private sealed class CapturingLogger : IOpenClawLogger + { + private readonly Action? _debug; + private readonly Action? _error; + + public CapturingLogger( + Action? debug = null, + Action? error = null) + { + _debug = debug; + _error = error; + } + + public void Debug(string message) => _debug?.Invoke(message); + public void Error(string message, Exception? ex = null) => _error?.Invoke(message, ex); + public void Info(string message) { } + public void Warn(string message) { } + } +}