Skip to content

#361: Allow untyped JSInterop.Setup methods, that matches with any type #451

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
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
26 changes: 25 additions & 1 deletion src/bunit.web/JSInterop/BunitJSInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ namespace Bunit
public class BunitJSInterop
{
private readonly Dictionary<Type, List<object>> handlers = new();
private JSRuntimeInvocationHandler? genericHandler;
private JSRuntimeMode mode;

/// <summary>
Expand Down Expand Up @@ -63,13 +64,36 @@ public void AddInvocationHandler<TResult>(JSRuntimeInvocationHandlerBase<TResult
handlers[resultType].Add(handler);
}


/// <summary>
/// Adds a generic invocation handler to bUnit's JSInterop.
/// The purpose of this untyped invocation handler is handle all js invocations.
/// </summary>
public void SetGenericInvocationHandler(JSRuntimeInvocationHandler? handler)
{
genericHandler = handler;
}

internal ValueTask<TValue> HandleInvocation<TValue>(JSRuntimeInvocation invocation)
{
RegisterInvocation(invocation);
return TryHandlePlannedInvocation<TValue>(invocation)
return TryGenericInvocation<TValue>(invocation)
?? TryHandlePlannedInvocation<TValue>(invocation)
?? new ValueTask<TValue>(default(TValue)!);
}

private ValueTask<TValue>? TryGenericInvocation<TValue>(JSRuntimeInvocation invocation)
{
ValueTask<TValue>? result = default;

if (genericHandler != null && genericHandler.HandleAsync(invocation) is Task<object> res)
{
result = new ValueTask<TValue>(res.ContinueWith(r => (TValue) r.Result, System.Threading.CancellationToken.None, TaskContinuationOptions.NotOnCanceled, TaskScheduler.Default));
}

return result;
}

private ValueTask<TValue>? TryHandlePlannedInvocation<TValue>(JSRuntimeInvocation invocation)
{
ValueTask<TValue>? result = default;
Expand Down
34 changes: 34 additions & 0 deletions src/bunit.web/JSInterop/BunitJSInteropSetupExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,40 @@ public static JSRuntimeInvocationHandler<TResult> Setup<TResult>(this BunitJSInt
public static JSRuntimeInvocationHandler<TResult> Setup<TResult>(this BunitJSInterop jsInterop)
=> Setup<TResult>(jsInterop, _ => true, isCatchAllHandler: true);

/// <summary>
/// Configure an untyped JSInterop invocation handler passing the <paramref name="invocationMatcher"/> test.
/// </summary>
/// <param name="jsInterop">The bUnit JSInterop to setup the invocation handling with.</param>
/// <param name="invocationMatcher">A matcher that is passed an <see cref="JSRuntimeInvocation"/>. If it returns true the invocation is matched.</param>
/// <returns>A <see cref="UntypedJSRuntimeInvocationHandler"/>.</returns>
public static UntypedJSRuntimeInvocationHandler Setup(this BunitJSInterop jsInterop, InvocationMatcher invocationMatcher)
{
if (jsInterop is null)
throw new ArgumentNullException(nameof(jsInterop));
return new UntypedJSRuntimeInvocationHandler(jsInterop, invocationMatcher);
}

/// <summary>
/// Configure an untyped JSInterop invocation handler with the <paramref name="identifier"/> and arguments
/// passing the <paramref name="invocationMatcher"/> test.
/// </summary>
/// <param name="jsInterop">The bUnit JSInterop to setup the invocation handling with.</param>
/// <param name="identifier">The identifier to setup a response for.</param>
/// <param name="invocationMatcher">A matcher that is passed an <see cref="JSRuntimeInvocation"/> associated with the<paramref name="identifier"/>. If it returns true the invocation is matched.</param>
/// <returns>A <see cref="UntypedJSRuntimeInvocationHandler"/>.</returns>
public static UntypedJSRuntimeInvocationHandler Setup(this BunitJSInterop jsInterop, string identifier, InvocationMatcher invocationMatcher)
=> Setup(jsInterop, inv => identifier.Equals(inv.Identifier, StringComparison.Ordinal) && invocationMatcher(inv));

/// <summary>
/// Configure an untyped JSInterop invocation handler with the <paramref name="identifier"/> and <paramref name="arguments"/>.
/// </summary>
/// <param name="jsInterop">The bUnit JSInterop to setup the invocation handling with.</param>
/// <param name="identifier">The identifier to setup a response for.</param>
/// <param name="arguments">The arguments that an invocation to <paramref name="identifier"/> should match.</param>
/// <returns>A <see cref="UntypedJSRuntimeInvocationHandler"/>.</returns>
public static UntypedJSRuntimeInvocationHandler Setup(this BunitJSInterop jsInterop, string identifier, params object?[]? arguments)
=> Setup(jsInterop, identifier, invocation => invocation.Arguments.SequenceEqual(arguments ?? Array.Empty<object?>()));

/// <summary>
/// Configure a JSInterop invocation handler for an <c>InvokeVoidAsync</c> call with arguments
/// passing the <paramref name="invocationMatcher"/> test, that should not receive any result.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using System.Threading.Tasks;

namespace Bunit.JSInterop.InvocationHandlers
{
internal class JSRuntimeInvocationHandlerFactory<TResult, TException> : JSRuntimeInvocationHandler<TResult>
where TException : Exception
{
private readonly Func<JSRuntimeInvocation, TResult>? resultFactory;
private readonly Func<JSRuntimeInvocation, TException>? exceptionFactory;

public JSRuntimeInvocationHandlerFactory(Func<JSRuntimeInvocation, TResult>? rFactory, Func<JSRuntimeInvocation, TException>? eFactory, InvocationMatcher matcher, bool isCatchAllHandler) : base(matcher, isCatchAllHandler)
{
resultFactory = rFactory;
exceptionFactory = eFactory;
}

protected override internal Task<TResult> HandleAsync(JSRuntimeInvocation invocation)
{
if (exceptionFactory != null && exceptionFactory.Invoke(invocation) is TException t)
{
base.SetException<TException>(t);
}
else if (resultFactory != null && resultFactory.Invoke(invocation) is TResult res)
{
base.SetResult(res);
}
return base.HandleAsync(invocation);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System;
using System.Threading.Tasks;

namespace Bunit.JSInterop.InvocationHandlers
{
/// <summary>
/// Represents a handler for an invocation of a JavaScript function with specific arguments
/// that can return any type, depending on the SetResult/SetCancelled/SetException invocations.
/// </summary>
public class UntypedJSRuntimeInvocationHandler : JSRuntimeInvocationHandler
{
private readonly InvocationMatcher invocationMatcher;

private readonly BunitJSInterop jsInterop;

private JSRuntimeInvocation? currentInvocation;

/// <summary>
/// Initializes a new instance of the <see cref="UntypedJSRuntimeInvocationHandler"/> class.
/// </summary>
/// <param name="interop">The bUnit JSInterop to setup the invocation handling with.</param>
/// <param name="matcher">An invocation matcher used to determine if the handler should handle an invocation.</param>
/// <param name="isCatchAllHandler">Set to true if this handler is a catch all handler, that should only be used if there are no other non-catch all handlers available.</param>
public UntypedJSRuntimeInvocationHandler(BunitJSInterop interop, InvocationMatcher matcher, bool isCatchAllHandler = true)
: base(matcher, isCatchAllHandler)
{
jsInterop = interop;
invocationMatcher = matcher ?? throw new ArgumentNullException(nameof(matcher));

jsInterop.SetGenericInvocationHandler(this);
}

/// <summary>
/// Sets the result factory function, that when invoked sets the <typeparamref name="TReturnType"/> result that invocations will receive.
/// </summary>
/// <param name="resultFactory">The result factory function that creates the handler result.</param>
/// <returns>This handler to allow calls to be chained.</returns>
public JSRuntimeInvocationHandler SetResult<TReturnType>(Func<JSRuntimeInvocation, TReturnType> resultFactory)
{
if (resultFactory == null)
{
throw new ArgumentNullException(nameof(resultFactory));
}

if (currentInvocation != null && resultFactory.Invoke(currentInvocation.Value) is TReturnType res)
{
base.SetResultBase(res);
}
else
{
var handler = new JSRuntimeInvocationHandlerFactory<TReturnType, Exception>(resultFactory, null, invocationMatcher, IsCatchAllHandler);

jsInterop.AddInvocationHandler<TReturnType>(handler);
jsInterop.SetGenericInvocationHandler(null);
}

return this;
}

/// <summary>
/// Sets the exception factory, that when invoked determines the <typeparamref name="TException"/> exception that invocations will receive.
/// </summary>
/// <param name="exceptionFactory">The exception function factory to set.</param>
/// <returns>This handler to allow calls to be chained.</returns>
public JSRuntimeInvocationHandler SetException<TReturnType, TException>(Func<JSRuntimeInvocation, TException> exceptionFactory) where TException : Exception
{
if (exceptionFactory == null)
{
throw new ArgumentNullException(nameof(exceptionFactory));
}

if (currentInvocation != null && exceptionFactory.Invoke(currentInvocation.Value) is TException excp)
{
base.SetException(excp);
}
else
{
var handler = new JSRuntimeInvocationHandlerFactory<TReturnType, TException>(null, exceptionFactory, invocationMatcher, IsCatchAllHandler);

jsInterop.AddInvocationHandler<TReturnType>(handler);
jsInterop.SetGenericInvocationHandler(null);
}

return this;
}

/// <summary>
/// Call this to have the this handler handle the <paramref name="invocation"/>.
/// </summary>
/// <param name="invocation">Invocation to handle.</param>
protected override internal Task<object> HandleAsync(JSRuntimeInvocation invocation)
{
currentInvocation = invocation;
return base.HandleAsync(invocation);
}
}
}
60 changes: 60 additions & 0 deletions tests/bunit.web.tests/JSInterop/BunitJSInteropTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -511,5 +511,65 @@ public void Test046()
exception.Invocation.Identifier.ShouldBe(identifier);
exception.Invocation.Arguments.ShouldBe(args);
}

[Fact(DisplayName = "Untyped setup can return a typed result from a factory function")]
public async Task Test059()
{
var sut = CreateSut(JSRuntimeMode.Strict);
var identifier = "func";

var jsRuntime = sut.JSRuntime;

var handler = sut.Setup(i => i.Identifier == identifier);
handler.SetResult(_ => false);

var i1 = await jsRuntime.InvokeAsync<bool>(identifier);
i1.ShouldBe(false);
}

[Fact(DisplayName = "Untyped setup can throw an exception from a factory function")]
public void Test060()
{
var sut = CreateSut(JSRuntimeMode.Strict);
var identifier = "func";

var jsRuntime = sut.JSRuntime;

var handler = sut.Setup(i => i.Identifier == identifier);
handler.SetException<bool, NotImplementedException>(_ => throw new NotImplementedException());

Should.Throw<NotImplementedException>(async () => await jsRuntime.InvokeAsync<bool>(identifier));
}

[Fact(DisplayName = "An untyped invocation handler can be canceled")]
public void Test061()
{
var sut = CreateSut(JSRuntimeMode.Strict);
var identifier = "func";

var jsRuntime = sut.JSRuntime;

var handler = sut.Setup(i => i.Identifier == identifier);
handler.SetCanceled();

var res = jsRuntime.InvokeAsync<bool>(identifier);
res.IsCanceled.ShouldBeTrue();
}

[Fact(DisplayName = "Untyped supports setting result after invocation")]
public async Task Test062()
{
var sut = CreateSut(JSRuntimeMode.Strict);
var identifier = "func";
var jsRuntime = sut.JSRuntime;

var handler = sut.Setup(i => i.Identifier == identifier);

var i1 = jsRuntime.InvokeAsync<bool>(identifier);

handler.SetResult(_ => false);

(await i1).ShouldBe(false);
}
}
}