diff --git a/src/bunit/BunitContext.cs b/src/bunit/BunitContext.cs index 31d7a28c1..948d44b15 100644 --- a/src/bunit/BunitContext.cs +++ b/src/bunit/BunitContext.cs @@ -13,7 +13,8 @@ public partial class BunitContext : IDisposable, IAsyncDisposable private BunitRenderer? bunitRenderer; /// - /// Gets or sets the default wait timeout used by "WaitFor" operations, i.e. . + /// Gets or sets the default wait timeout used by "WaitFor" operations, i.e. , + /// and JSInterop invocation handlers that have not been configured with results. /// /// The default is 1 second. public static TimeSpan DefaultWaitTimeout { get; set; } = TimeSpan.FromSeconds(1); diff --git a/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs b/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs index 5d423698e..5aa093eb2 100644 --- a/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs +++ b/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs @@ -3,10 +3,13 @@ namespace Bunit; /// /// Represents an invocation handler for instances. /// -public abstract class JSRuntimeInvocationHandlerBase +public abstract class JSRuntimeInvocationHandlerBase : IDisposable { private readonly InvocationMatcher invocationMatcher; private TaskCompletionSource completionSource; + private Timer? timeoutTimer; + private JSRuntimeInvocation? currentInvocation; + private bool disposed; /// /// Gets a value indicating whether this handler is set up to handle calls to InvokeVoidAsync(string, object[]). @@ -40,6 +43,7 @@ protected JSRuntimeInvocationHandlerBase(InvocationMatcher matcher, bool isCatch /// protected void SetCanceledBase() { + ClearTimeoutTimer(); if (completionSource.Task.IsCompleted) completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -53,6 +57,7 @@ protected void SetCanceledBase() protected void SetExceptionBase(TException exception) where TException : Exception { + ClearTimeoutTimer(); if (completionSource.Task.IsCompleted) completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -65,6 +70,7 @@ protected void SetExceptionBase(TException exception) /// The type of result to pass to the callers. protected void SetResultBase(TResult result) { + ClearTimeoutTimer(); if (completionSource.Task.IsCompleted) completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -82,7 +88,19 @@ protected void SetResultBase(TResult result) protected internal virtual Task HandleAsync(JSRuntimeInvocation invocation) { Invocations.RegisterInvocation(invocation); - return completionSource.Task; + + var task = completionSource.Task; + if (task is { IsCanceled: false, IsFaulted: false, IsCompletedSuccessfully: false }) + { + if (BunitContext.DefaultWaitTimeout <= TimeSpan.Zero) + { + throw new JSRuntimeInvocationNotSetException(invocation); + } + + StartTimeoutTimer(invocation); + } + + return task; } /// @@ -91,4 +109,47 @@ protected internal virtual Task HandleAsync(JSRuntimeInvocation invocat /// Invocation to check. /// True if the handler can handle the invocation, false otherwise. internal bool CanHandle(JSRuntimeInvocation invocation) => invocationMatcher(invocation); + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + protected virtual void Dispose(bool disposing) + { + if (!disposed && disposing) + { + ClearTimeoutTimer(); + disposed = true; + } + } + + private void StartTimeoutTimer(JSRuntimeInvocation invocation) + { + ClearTimeoutTimer(); + + currentInvocation = invocation; + timeoutTimer = new Timer(OnTimeoutElapsed, null, BunitContext.DefaultWaitTimeout, Timeout.InfiniteTimeSpan); + } + + private void ClearTimeoutTimer() + { + timeoutTimer?.Dispose(); + timeoutTimer = null; + currentInvocation = null; + } + + private void OnTimeoutElapsed(object? state) + { + if (!completionSource.Task.IsCompleted && currentInvocation.HasValue) + { + var exception = new JSRuntimeInvocationNotSetException(currentInvocation.Value); + completionSource.TrySetException(exception); + } + + ClearTimeoutTimer(); + } } diff --git a/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs b/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs new file mode 100644 index 000000000..4eff577f1 --- /dev/null +++ b/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs @@ -0,0 +1,132 @@ +using System.Text; + +namespace Bunit; + +/// +/// Exception used to indicate that an invocation was received by a JSRuntime invocation handler, +/// but the handler was not configured with a result (via SetResult, SetVoidResult, SetCanceled, or SetException). +/// This causes the invocation to hang indefinitely. +/// +public sealed class JSRuntimeInvocationNotSetException : Exception +{ + /// + /// Gets the invocation that was not handled with a result. + /// + public JSRuntimeInvocation Invocation { get; } + + /// + /// Initializes a new instance of the class + /// with the provided attached. + /// + /// The invocation that was not provided with a result. + public JSRuntimeInvocationNotSetException(JSRuntimeInvocation invocation) + : base(CreateErrorMessage(invocation)) + { + Invocation = invocation; + } + + [SuppressMessage("Minor Code Smell", "S6618:\"string.Create\" should be used instead of \"FormattableString\"", Justification = "string.Create not supported in all TFs")] + private static string CreateErrorMessage(JSRuntimeInvocation invocation) + { + var sb = new StringBuilder(); + sb.AppendLine("bUnit's JSInterop invocation handler was setup to handle the call:"); + sb.AppendLine(); + + if (invocation.IsVoidResultInvocation) + { + sb.AppendLine(FormattableString.Invariant($" {invocation.InvocationMethodName}({GetArguments(invocation)})")); + } + else + { + sb.AppendLine(FormattableString.Invariant($" {invocation.InvocationMethodName}<{GetGenericInvocationArguments(invocation)}>({GetArguments(invocation)})")); + } + + sb.AppendLine(); + sb.AppendLine("However, the invocation handler was not configured to return a result,"); + sb.AppendLine("causing the invocation to hang indefinitely."); + sb.AppendLine(); + sb.AppendLine("To fix this, configure the handler to return a result using one of the following methods:"); + sb.AppendLine(); + + if (invocation.IsVoidResultInvocation) + { + sb.AppendLine(" handler.SetVoidResult();"); + } + else + { + sb.AppendLine(FormattableString.Invariant($" handler.SetResult({GetExampleResult(invocation.ResultType)});")); + } + + sb.AppendLine(" handler.SetCanceled();"); + sb.AppendLine(" handler.SetException(new Exception(\"error message\"));"); + return sb.ToString(); + } + + private static string GetArguments(JSRuntimeInvocation invocation) + { + if (!invocation.Arguments.Any()) + return $"\"{invocation.Identifier}\""; + + var argStrings = invocation.Arguments.Select(FormatArgument).Prepend($"\"{invocation.Identifier}\""); + return string.Join(", ", argStrings); + } + + private static string GetGenericInvocationArguments(JSRuntimeInvocation invocation) + { + return GetReturnTypeName(invocation.ResultType); + } + + private static string FormatArgument(object? arg) + { + return arg switch + { + null => "null", + string str => $"\"{str}\"", + char c => $"'{c}'", + bool b => b.ToString().ToUpperInvariant(), + _ => arg.ToString() ?? "null" + }; + } + + private static string GetReturnTypeName(Type resultType) + => resultType switch + { + Type { FullName: "System.Boolean" } => "bool", + Type { FullName: "System.Byte" } => "byte", + Type { FullName: "System.Char" } => "char", + Type { FullName: "System.Double" } => "double", + Type { FullName: "System.Int16" } => "short", + Type { FullName: "System.Int32" } => "int", + Type { FullName: "System.Int64" } => "long", + Type { FullName: "System.Single" } => "float", + Type { FullName: "System.String" } => "string", + Type { FullName: "System.Decimal" } => "decimal", + Type { FullName: "System.Guid" } => "Guid", + Type { FullName: "System.DateTime" } => "DateTime", + Type { FullName: "System.DateTimeOffset" } => "DateTimeOffset", + Type { FullName: "System.TimeSpan" } => "TimeSpan", + Type { FullName: "System.Object" } => "object", + _ => resultType.Name + }; + + private static string GetExampleResult(Type resultType) + => resultType switch + { + Type { FullName: "System.Boolean" } => "true", + Type { FullName: "System.Byte" } => "1", + Type { FullName: "System.Char" } => "'a'", + Type { FullName: "System.Double" } => "1.0", + Type { FullName: "System.Int16" } => "1", + Type { FullName: "System.Int32" } => "1", + Type { FullName: "System.Int64" } => "1L", + Type { FullName: "System.Single" } => "1.0f", + Type { FullName: "System.String" } => "\"result\"", + Type { FullName: "System.Decimal" } => "1.0m", + Type { FullName: "System.Guid" } => "Guid.NewGuid()", + Type { FullName: "System.DateTime" } => "DateTime.Now", + Type { FullName: "System.DateTimeOffset" } => "DateTimeOffset.Now", + Type { FullName: "System.TimeSpan" } => "TimeSpan.FromSeconds(1)", + Type { FullName: "System.Object" } => "new object()", + _ => $"new {resultType.Name}()" + }; +} diff --git a/tests/bunit.tests/JSInterop/BunitJSInteropTest.cs b/tests/bunit.tests/JSInterop/BunitJSInteropTest.cs index 0a3826363..f5140f855 100644 --- a/tests/bunit.tests/JSInterop/BunitJSInteropTest.cs +++ b/tests/bunit.tests/JSInterop/BunitJSInteropTest.cs @@ -686,4 +686,20 @@ public void Test308(string identifier, string arg0, string arg1, string arg2) invocationMethodName: "InvokeUnmarshalled")); } #endif + + [Fact(DisplayName = "JSRuntime invocation times out when handler is not configured")] + public async Task Test309() + { + // Arrange + const string identifier = "testFunction"; + BunitContext.DefaultWaitTimeout = TimeSpan.FromMilliseconds(100); + + var sut = CreateSut(JSRuntimeMode.Strict); + sut.Setup(identifier); + + var invocationTask = sut.JSRuntime.InvokeAsync(identifier); + + var exception = await Should.ThrowAsync(invocationTask.AsTask()); + exception.Invocation.Identifier.ShouldBe(identifier); + } }