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);
+ }
}