Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion src/bunit/BunitContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public partial class BunitContext : IDisposable, IAsyncDisposable
private BunitRenderer? bunitRenderer;

/// <summary>
/// Gets or sets the default wait timeout used by "WaitFor" operations, i.e. <see cref="RenderedComponentWaitForHelperExtensions.WaitForAssertion{TComponent}(IRenderedComponent{TComponent}, Action, TimeSpan?)"/>.
/// Gets or sets the default wait timeout used by "WaitFor" operations, i.e. <see cref="RenderedComponentWaitForHelperExtensions.WaitForAssertion{TComponent}(IRenderedComponent{TComponent}, Action, TimeSpan?)"/>,
/// and JSInterop invocation handlers that have not been configured with results.
/// </summary>
/// <remarks>The default is 1 second.</remarks>
public static TimeSpan DefaultWaitTimeout { get; set; } = TimeSpan.FromSeconds(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ namespace Bunit;
/// <summary>
/// Represents an invocation handler for <see cref="JSRuntimeInvocation"/> instances.
/// </summary>
public abstract class JSRuntimeInvocationHandlerBase<TResult>
public abstract class JSRuntimeInvocationHandlerBase<TResult> : IDisposable
{
private readonly InvocationMatcher invocationMatcher;
private TaskCompletionSource<TResult> completionSource;
private Timer? timeoutTimer;
private JSRuntimeInvocation? currentInvocation;
private bool disposed;

/// <summary>
/// Gets a value indicating whether this handler is set up to handle calls to <c>InvokeVoidAsync(string, object[])</c>.
Expand Down Expand Up @@ -40,6 +43,7 @@ protected JSRuntimeInvocationHandlerBase(InvocationMatcher matcher, bool isCatch
/// </summary>
protected void SetCanceledBase()
{
ClearTimeoutTimer();
if (completionSource.Task.IsCompleted)
completionSource = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);

Expand All @@ -53,6 +57,7 @@ protected void SetCanceledBase()
protected void SetExceptionBase<TException>(TException exception)
where TException : Exception
{
ClearTimeoutTimer();
if (completionSource.Task.IsCompleted)
completionSource = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);

Expand All @@ -65,6 +70,7 @@ protected void SetExceptionBase<TException>(TException exception)
/// <param name="result">The type of result to pass to the callers.</param>
protected void SetResultBase(TResult result)
{
ClearTimeoutTimer();
if (completionSource.Task.IsCompleted)
completionSource = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);

Expand All @@ -82,7 +88,19 @@ protected void SetResultBase(TResult result)
protected internal virtual Task<TResult> 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;
}

/// <summary>
Expand All @@ -91,4 +109,47 @@ protected internal virtual Task<TResult> HandleAsync(JSRuntimeInvocation invocat
/// <param name="invocation">Invocation to check.</param>
/// <returns>True if the handler can handle the invocation, false otherwise.</returns>
internal bool CanHandle(JSRuntimeInvocation invocation) => invocationMatcher(invocation);

/// <inheritdoc/>
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}

/// <inheritdoc/>
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();
}
}
132 changes: 132 additions & 0 deletions src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.Text;

namespace Bunit;

/// <summary>
/// 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.
/// </summary>
public sealed class JSRuntimeInvocationNotSetException : Exception
{
/// <summary>
/// Gets the invocation that was not handled with a result.
/// </summary>
public JSRuntimeInvocation Invocation { get; }

/// <summary>
/// Initializes a new instance of the <see cref="JSRuntimeInvocationNotSetException"/> class
/// with the provided <see cref="Invocation"/> attached.
/// </summary>
/// <param name="invocation">The invocation that was not provided with a result.</param>
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}()"
};
}
16 changes: 16 additions & 0 deletions tests/bunit.tests/JSInterop/BunitJSInteropTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(identifier);

var invocationTask = sut.JSRuntime.InvokeAsync<int>(identifier);

var exception = await Should.ThrowAsync<JSRuntimeInvocationNotSetException>(invocationTask.AsTask());
exception.Invocation.Identifier.ShouldBe(identifier);
}
}
Loading