Skip to content

Commit 9d83fb5

Browse files
committed
feat: Error when no setup is made for JS
1 parent 47f9c22 commit 9d83fb5

File tree

4 files changed

+234
-3
lines changed

4 files changed

+234
-3
lines changed

src/bunit/BunitContext.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ public partial class BunitContext : IDisposable, IAsyncDisposable
1313
private BunitRenderer? bunitRenderer;
1414

1515
/// <summary>
16-
/// Gets or sets the default wait timeout used by "WaitFor" operations, i.e. <see cref="RenderedComponentWaitForHelperExtensions.WaitForAssertion{TComponent}(IRenderedComponent{TComponent}, Action, TimeSpan?)"/>.
16+
/// Gets or sets the default wait timeout used by "WaitFor" operations, i.e. <see cref="RenderedComponentWaitForHelperExtensions.WaitForAssertion{TComponent}(IRenderedComponent{TComponent}, Action, TimeSpan?)"/>,
17+
/// and JSInterop invocation handlers that have not been configured with results.
1718
/// </summary>
1819
/// <remarks>The default is 1 second.</remarks>
1920
public static TimeSpan DefaultWaitTimeout { get; set; } = TimeSpan.FromSeconds(1);

src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ namespace Bunit;
33
/// <summary>
44
/// Represents an invocation handler for <see cref="JSRuntimeInvocation"/> instances.
55
/// </summary>
6-
public abstract class JSRuntimeInvocationHandlerBase<TResult>
6+
public abstract class JSRuntimeInvocationHandlerBase<TResult> : IDisposable
77
{
88
private readonly InvocationMatcher invocationMatcher;
99
private TaskCompletionSource<TResult> completionSource;
10+
private Timer? timeoutTimer;
11+
private JSRuntimeInvocation? currentInvocation;
12+
private bool disposed;
1013

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

@@ -53,6 +57,7 @@ protected void SetCanceledBase()
5357
protected void SetExceptionBase<TException>(TException exception)
5458
where TException : Exception
5559
{
60+
ClearTimeoutTimer();
5661
if (completionSource.Task.IsCompleted)
5762
completionSource = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);
5863

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

@@ -82,7 +88,21 @@ protected void SetResultBase(TResult result)
8288
protected internal virtual Task<TResult> HandleAsync(JSRuntimeInvocation invocation)
8389
{
8490
Invocations.RegisterInvocation(invocation);
85-
return completionSource.Task;
91+
92+
var task = completionSource.Task;
93+
if (task is { IsCanceled: false, IsFaulted: false, IsCompletedSuccessfully: false })
94+
{
95+
// Check if timeout is disabled (zero or negative) - throw immediately
96+
if (BunitContext.DefaultWaitTimeout <= TimeSpan.Zero)
97+
{
98+
throw new JSRuntimeInvocationNotSetException(invocation);
99+
}
100+
101+
// Start timeout timer
102+
StartTimeoutTimer(invocation);
103+
}
104+
105+
return task;
86106
}
87107

88108
/// <summary>
@@ -91,4 +111,66 @@ protected internal virtual Task<TResult> HandleAsync(JSRuntimeInvocation invocat
91111
/// <param name="invocation">Invocation to check.</param>
92112
/// <returns>True if the handler can handle the invocation, false otherwise.</returns>
93113
internal bool CanHandle(JSRuntimeInvocation invocation) => invocationMatcher(invocation);
114+
115+
/// <summary>
116+
/// Releases all resources used by the JSRuntimeInvocationHandlerBase.
117+
/// </summary>
118+
public void Dispose()
119+
{
120+
Dispose(disposing: true);
121+
GC.SuppressFinalize(this);
122+
}
123+
124+
/// <summary>
125+
/// Releases all resources used by the JSRuntimeInvocationHandlerBase.
126+
/// </summary>
127+
/// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
128+
protected virtual void Dispose(bool disposing)
129+
{
130+
if (!disposed && disposing)
131+
{
132+
ClearTimeoutTimer();
133+
disposed = true;
134+
}
135+
}
136+
137+
/// <summary>
138+
/// Starts a timeout timer to detect when an invocation handler has not been configured with a result.
139+
/// </summary>
140+
/// <param name="invocation">The invocation to track.</param>
141+
private void StartTimeoutTimer(JSRuntimeInvocation invocation)
142+
{
143+
// Clear any existing timer
144+
ClearTimeoutTimer();
145+
146+
currentInvocation = invocation;
147+
timeoutTimer = new Timer(OnTimeoutElapsed, null, BunitContext.DefaultWaitTimeout, Timeout.InfiniteTimeSpan);
148+
}
149+
150+
/// <summary>
151+
/// Clears the timeout timer if it exists.
152+
/// </summary>
153+
private void ClearTimeoutTimer()
154+
{
155+
timeoutTimer?.Dispose();
156+
timeoutTimer = null;
157+
currentInvocation = null;
158+
}
159+
160+
/// <summary>
161+
/// Called when the timeout timer elapses, indicating that the invocation handler
162+
/// was not configured with a result within the timeout period.
163+
/// </summary>
164+
/// <param name="state">Timer state (unused).</param>
165+
private void OnTimeoutElapsed(object? state)
166+
{
167+
// Check if the completion source is still pending
168+
if (!completionSource.Task.IsCompleted && currentInvocation.HasValue)
169+
{
170+
var exception = new JSRuntimeInvocationNotSetException(currentInvocation.Value);
171+
completionSource.TrySetException(exception);
172+
}
173+
174+
ClearTimeoutTimer();
175+
}
94176
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using System.Text;
2+
3+
namespace Bunit;
4+
5+
/// <summary>
6+
/// Exception used to indicate that an invocation was received by a JSRuntime invocation handler,
7+
/// but the handler was not configured with a result (via SetResult, SetVoidResult, SetCanceled, or SetException).
8+
/// This causes the invocation to hang indefinitely.
9+
/// </summary>
10+
public sealed class JSRuntimeInvocationNotSetException : Exception
11+
{
12+
/// <summary>
13+
/// Gets the invocation that was not handled with a result.
14+
/// </summary>
15+
public JSRuntimeInvocation Invocation { get; }
16+
17+
/// <summary>
18+
/// Initializes a new instance of the <see cref="JSRuntimeInvocationNotSetException"/> class
19+
/// with the provided <see cref="Invocation"/> attached.
20+
/// </summary>
21+
/// <param name="invocation">The invocation that was not provided with a result.</param>
22+
public JSRuntimeInvocationNotSetException(JSRuntimeInvocation invocation)
23+
: base(CreateErrorMessage(invocation))
24+
{
25+
Invocation = invocation;
26+
}
27+
28+
[SuppressMessage("Minor Code Smell", "S6618:\"string.Create\" should be used instead of \"FormattableString\"", Justification = "string.Create not supported in all TFs")]
29+
private static string CreateErrorMessage(JSRuntimeInvocation invocation)
30+
{
31+
var sb = new StringBuilder();
32+
sb.AppendLine("bUnit's JSInterop invocation handler was setup to handle the call:");
33+
sb.AppendLine();
34+
35+
if (invocation.IsVoidResultInvocation)
36+
{
37+
sb.AppendLine(FormattableString.Invariant($" {invocation.InvocationMethodName}({GetArguments(invocation)})"));
38+
}
39+
else
40+
{
41+
sb.AppendLine(FormattableString.Invariant($" {invocation.InvocationMethodName}<{GetGenericInvocationArguments(invocation)}>({GetArguments(invocation)})"));
42+
}
43+
44+
sb.AppendLine();
45+
sb.AppendLine("However, the invocation handler was not configured to return a result,");
46+
sb.AppendLine("causing the invocation to hang indefinitely.");
47+
sb.AppendLine();
48+
sb.AppendLine("To fix this, configure the handler to return a result using one of the following methods:");
49+
sb.AppendLine();
50+
51+
if (invocation.IsVoidResultInvocation)
52+
{
53+
sb.AppendLine(" handler.SetVoidResult();");
54+
}
55+
else
56+
{
57+
sb.AppendLine(FormattableString.Invariant($" handler.SetResult({GetExampleResult(invocation.ResultType)});"));
58+
}
59+
60+
sb.AppendLine(" handler.SetCanceled();");
61+
sb.AppendLine(" handler.SetException(new Exception(\"error message\"));");
62+
return sb.ToString();
63+
}
64+
65+
private static string GetArguments(JSRuntimeInvocation invocation)
66+
{
67+
if (!invocation.Arguments.Any())
68+
return $"\"{invocation.Identifier}\"";
69+
70+
var argStrings = invocation.Arguments.Select(FormatArgument).Prepend($"\"{invocation.Identifier}\"");
71+
return string.Join(", ", argStrings);
72+
}
73+
74+
private static string GetGenericInvocationArguments(JSRuntimeInvocation invocation)
75+
{
76+
return GetReturnTypeName(invocation.ResultType);
77+
}
78+
79+
private static string FormatArgument(object? arg)
80+
{
81+
return arg switch
82+
{
83+
null => "null",
84+
string str => $"\"{str}\"",
85+
char c => $"'{c}'",
86+
bool b => b.ToString().ToUpperInvariant(),
87+
_ => arg.ToString() ?? "null"
88+
};
89+
}
90+
91+
private static string GetReturnTypeName(Type resultType)
92+
=> resultType switch
93+
{
94+
Type { FullName: "System.Boolean" } => "bool",
95+
Type { FullName: "System.Byte" } => "byte",
96+
Type { FullName: "System.Char" } => "char",
97+
Type { FullName: "System.Double" } => "double",
98+
Type { FullName: "System.Int16" } => "short",
99+
Type { FullName: "System.Int32" } => "int",
100+
Type { FullName: "System.Int64" } => "long",
101+
Type { FullName: "System.Single" } => "float",
102+
Type { FullName: "System.String" } => "string",
103+
Type { FullName: "System.Decimal" } => "decimal",
104+
Type { FullName: "System.Guid" } => "Guid",
105+
Type { FullName: "System.DateTime" } => "DateTime",
106+
Type { FullName: "System.DateTimeOffset" } => "DateTimeOffset",
107+
Type { FullName: "System.TimeSpan" } => "TimeSpan",
108+
Type { FullName: "System.Object" } => "object",
109+
_ => resultType.Name
110+
};
111+
112+
private static string GetExampleResult(Type resultType)
113+
=> resultType switch
114+
{
115+
Type { FullName: "System.Boolean" } => "true",
116+
Type { FullName: "System.Byte" } => "1",
117+
Type { FullName: "System.Char" } => "'a'",
118+
Type { FullName: "System.Double" } => "1.0",
119+
Type { FullName: "System.Int16" } => "1",
120+
Type { FullName: "System.Int32" } => "1",
121+
Type { FullName: "System.Int64" } => "1L",
122+
Type { FullName: "System.Single" } => "1.0f",
123+
Type { FullName: "System.String" } => "\"result\"",
124+
Type { FullName: "System.Decimal" } => "1.0m",
125+
Type { FullName: "System.Guid" } => "Guid.NewGuid()",
126+
Type { FullName: "System.DateTime" } => "DateTime.Now",
127+
Type { FullName: "System.DateTimeOffset" } => "DateTimeOffset.Now",
128+
Type { FullName: "System.TimeSpan" } => "TimeSpan.FromSeconds(1)",
129+
Type { FullName: "System.Object" } => "new object()",
130+
_ => $"new {resultType.Name}()"
131+
};
132+
}

tests/bunit.tests/JSInterop/BunitJSInteropTest.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,4 +686,20 @@ public void Test308(string identifier, string arg0, string arg1, string arg2)
686686
invocationMethodName: "InvokeUnmarshalled"));
687687
}
688688
#endif
689+
690+
[Fact(DisplayName = "JSRuntime invocation times out when handler is not configured")]
691+
public async Task Test309()
692+
{
693+
// Arrange
694+
const string identifier = "testFunction";
695+
BunitContext.DefaultWaitTimeout = TimeSpan.FromMilliseconds(100);
696+
697+
var sut = CreateSut(JSRuntimeMode.Strict);
698+
sut.Setup<int>(identifier);
699+
700+
var invocationTask = sut.JSRuntime.InvokeAsync<int>(identifier);
701+
702+
var exception = await Should.ThrowAsync<JSRuntimeInvocationNotSetException>(invocationTask.AsTask());
703+
exception.Invocation.Identifier.ShouldBe(identifier);
704+
}
689705
}

0 commit comments

Comments
 (0)