diff --git a/src/bunit.web/JSInterop/BunitJSInterop.cs b/src/bunit.web/JSInterop/BunitJSInterop.cs index 6da3a157b..e3c129164 100644 --- a/src/bunit.web/JSInterop/BunitJSInterop.cs +++ b/src/bunit.web/JSInterop/BunitJSInterop.cs @@ -16,6 +16,7 @@ namespace Bunit public class BunitJSInterop { private readonly Dictionary> handlers = new(); + private JSRuntimeInvocationHandler? genericHandler; private JSRuntimeMode mode; /// @@ -63,13 +64,36 @@ public void AddInvocationHandler(JSRuntimeInvocationHandlerBase + /// Adds a generic invocation handler to bUnit's JSInterop. + /// The purpose of this untyped invocation handler is handle all js invocations. + /// + public void SetGenericInvocationHandler(JSRuntimeInvocationHandler? handler) + { + genericHandler = handler; + } + internal ValueTask HandleInvocation(JSRuntimeInvocation invocation) { RegisterInvocation(invocation); - return TryHandlePlannedInvocation(invocation) + return TryGenericInvocation(invocation) + ?? TryHandlePlannedInvocation(invocation) ?? new ValueTask(default(TValue)!); } + private ValueTask? TryGenericInvocation(JSRuntimeInvocation invocation) + { + ValueTask? result = default; + + if (genericHandler != null && genericHandler.HandleAsync(invocation) is Task res) + { + result = new ValueTask(res.ContinueWith(r => (TValue) r.Result, System.Threading.CancellationToken.None, TaskContinuationOptions.NotOnCanceled, TaskScheduler.Default)); + } + + return result; + } + private ValueTask? TryHandlePlannedInvocation(JSRuntimeInvocation invocation) { ValueTask? result = default; diff --git a/src/bunit.web/JSInterop/BunitJSInteropSetupExtensions.cs b/src/bunit.web/JSInterop/BunitJSInteropSetupExtensions.cs index 00c081078..4bd30ebc8 100644 --- a/src/bunit.web/JSInterop/BunitJSInteropSetupExtensions.cs +++ b/src/bunit.web/JSInterop/BunitJSInteropSetupExtensions.cs @@ -67,6 +67,40 @@ public static JSRuntimeInvocationHandler Setup(this BunitJSInt public static JSRuntimeInvocationHandler Setup(this BunitJSInterop jsInterop) => Setup(jsInterop, _ => true, isCatchAllHandler: true); + /// + /// Configure an untyped JSInterop invocation handler passing the test. + /// + /// The bUnit JSInterop to setup the invocation handling with. + /// A matcher that is passed an . If it returns true the invocation is matched. + /// A . + public static UntypedJSRuntimeInvocationHandler Setup(this BunitJSInterop jsInterop, InvocationMatcher invocationMatcher) + { + if (jsInterop is null) + throw new ArgumentNullException(nameof(jsInterop)); + return new UntypedJSRuntimeInvocationHandler(jsInterop, invocationMatcher); + } + + /// + /// Configure an untyped JSInterop invocation handler with the and arguments + /// passing the test. + /// + /// The bUnit JSInterop to setup the invocation handling with. + /// The identifier to setup a response for. + /// A matcher that is passed an associated with the. If it returns true the invocation is matched. + /// A . + public static UntypedJSRuntimeInvocationHandler Setup(this BunitJSInterop jsInterop, string identifier, InvocationMatcher invocationMatcher) + => Setup(jsInterop, inv => identifier.Equals(inv.Identifier, StringComparison.Ordinal) && invocationMatcher(inv)); + + /// + /// Configure an untyped JSInterop invocation handler with the and . + /// + /// The bUnit JSInterop to setup the invocation handling with. + /// The identifier to setup a response for. + /// The arguments that an invocation to should match. + /// A . + public static UntypedJSRuntimeInvocationHandler Setup(this BunitJSInterop jsInterop, string identifier, params object?[]? arguments) + => Setup(jsInterop, identifier, invocation => invocation.Arguments.SequenceEqual(arguments ?? Array.Empty())); + /// /// Configure a JSInterop invocation handler for an InvokeVoidAsync call with arguments /// passing the test, that should not receive any result. diff --git a/src/bunit.web/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerFactory.cs b/src/bunit.web/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerFactory.cs new file mode 100644 index 000000000..bc1a2a3e3 --- /dev/null +++ b/src/bunit.web/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerFactory.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading.Tasks; + +namespace Bunit.JSInterop.InvocationHandlers +{ + internal class JSRuntimeInvocationHandlerFactory : JSRuntimeInvocationHandler + where TException : Exception + { + private readonly Func? resultFactory; + private readonly Func? exceptionFactory; + + public JSRuntimeInvocationHandlerFactory(Func? rFactory, Func? eFactory, InvocationMatcher matcher, bool isCatchAllHandler) : base(matcher, isCatchAllHandler) + { + resultFactory = rFactory; + exceptionFactory = eFactory; + } + + protected override internal Task HandleAsync(JSRuntimeInvocation invocation) + { + if (exceptionFactory != null && exceptionFactory.Invoke(invocation) is TException t) + { + base.SetException(t); + } + else if (resultFactory != null && resultFactory.Invoke(invocation) is TResult res) + { + base.SetResult(res); + } + return base.HandleAsync(invocation); + } + } +} \ No newline at end of file diff --git a/src/bunit.web/JSInterop/InvocationHandlers/UntypedJSRuntimeInvocationHandler.cs b/src/bunit.web/JSInterop/InvocationHandlers/UntypedJSRuntimeInvocationHandler.cs new file mode 100644 index 000000000..06f9e55c1 --- /dev/null +++ b/src/bunit.web/JSInterop/InvocationHandlers/UntypedJSRuntimeInvocationHandler.cs @@ -0,0 +1,97 @@ +using System; +using System.Threading.Tasks; + +namespace Bunit.JSInterop.InvocationHandlers +{ + /// + /// 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. + /// + public class UntypedJSRuntimeInvocationHandler : JSRuntimeInvocationHandler + { + private readonly InvocationMatcher invocationMatcher; + + private readonly BunitJSInterop jsInterop; + + private JSRuntimeInvocation? currentInvocation; + + /// + /// Initializes a new instance of the class. + /// + /// The bUnit JSInterop to setup the invocation handling with. + /// An invocation matcher used to determine if the handler should handle an invocation. + /// 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. + public UntypedJSRuntimeInvocationHandler(BunitJSInterop interop, InvocationMatcher matcher, bool isCatchAllHandler = true) + : base(matcher, isCatchAllHandler) + { + jsInterop = interop; + invocationMatcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); + + jsInterop.SetGenericInvocationHandler(this); + } + + /// + /// Sets the result factory function, that when invoked sets the result that invocations will receive. + /// + /// The result factory function that creates the handler result. + /// This handler to allow calls to be chained. + public JSRuntimeInvocationHandler SetResult(Func 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(resultFactory, null, invocationMatcher, IsCatchAllHandler); + + jsInterop.AddInvocationHandler(handler); + jsInterop.SetGenericInvocationHandler(null); + } + + return this; + } + + /// + /// Sets the exception factory, that when invoked determines the exception that invocations will receive. + /// + /// The exception function factory to set. + /// This handler to allow calls to be chained. + public JSRuntimeInvocationHandler SetException(Func 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(null, exceptionFactory, invocationMatcher, IsCatchAllHandler); + + jsInterop.AddInvocationHandler(handler); + jsInterop.SetGenericInvocationHandler(null); + } + + return this; + } + + /// + /// Call this to have the this handler handle the . + /// + /// Invocation to handle. + protected override internal Task HandleAsync(JSRuntimeInvocation invocation) + { + currentInvocation = invocation; + return base.HandleAsync(invocation); + } + } +} \ No newline at end of file diff --git a/tests/bunit.web.tests/JSInterop/BunitJSInteropTest.cs b/tests/bunit.web.tests/JSInterop/BunitJSInteropTest.cs index 3ef2f6757..5031805c7 100644 --- a/tests/bunit.web.tests/JSInterop/BunitJSInteropTest.cs +++ b/tests/bunit.web.tests/JSInterop/BunitJSInteropTest.cs @@ -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(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(_ => throw new NotImplementedException()); + + Should.Throw(async () => await jsRuntime.InvokeAsync(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(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(identifier); + + handler.SetResult(_ => false); + + (await i1).ShouldBe(false); + } } }