Skip to content

Commit d66c69d

Browse files
authored
Merge pull request #247 from egil/feature/237-jsinterop-always-on
JSInterop "always on" in TestContext
2 parents 83b1a50 + 73a08bf commit d66c69d

44 files changed

Lines changed: 630 additions & 681 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ List of new features.
1414
### Changed
1515
List of changes in existing functionality.
1616

17+
- bUnit's mock IJSRuntime has been moved to an "always on" state by default, in strict mode, and is now available through `TestContext`'s `JSInterop` property. This makes it possible for first party Blazor components like the `<Virtualize>` component, which depend on JSInterop, to "just work" in tests.
18+
19+
**Compatible with previous releases:** To get the same effect as calling `Services.AddMockJSRuntime()` in beta-11, which used to add the mock IJSRuntime in "loose" mode, you now just need to change the mode of the already on JSInterop, i.e. `ctx.JSInterop.Mode = JSRuntimeMode.Loose`.
20+
21+
**Inspect registered handlers:** Since the new design allows registering invoke handlers in the context of the `TestContext`, you might need to get already registered handlers in your individual tests. This can be done with the `TryGetInvokeHandler()` method, that will return handler that can handle the parameters passed to it. E.g. to get a handler for a `IJSRuntime.InvokaAsync<string>("getValue")`, call `ctx.JSInterop.TryGetInvokeHandler<string>("getValue")`.
22+
23+
Learn more [issue #237](https://github.com/egil/bUnit/issues/237). By [@egil](https://github.com/egil) in [#247](https://github.com/egil/bUnit/pull/247).
24+
1725
### Removed
1826
List of now removed features.
1927

src/bunit.core/ComponentParameterCollectionBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public sealed class ComponentParameterCollectionBuilder<TComponent> where TCompo
2626
.OfType<ParameterAttribute>()
2727
.Any(x => x.CaptureUnmatchedValues);
2828

29-
private readonly ComponentParameterCollection _parameters = new ComponentParameterCollection();
29+
private readonly ComponentParameterCollection _parameters = new();
3030

3131
/// <summary>
3232
/// Creates an instance of the <see cref="ComponentParameterCollectionBuilder{TComponent}"/>.

src/bunit.core/Rendering/RenderTreeFrameCollection.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace Bunit.Rendering
88
/// </summary>
99
public sealed class RenderTreeFrameCollection
1010
{
11-
private readonly Dictionary<int, ArrayRange<RenderTreeFrame>> _currentRenderTree = new Dictionary<int, ArrayRange<RenderTreeFrame>>();
11+
private readonly Dictionary<int, ArrayRange<RenderTreeFrame>> _currentRenderTree = new();
1212

1313
/// <summary>
1414
/// Gets the <see cref="ArrayRange{RenderTreeFrame}"/> associated with the <paramref name="componentId"/>.

src/bunit.core/Rendering/TestRenderer.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ namespace Bunit.Rendering
1414
/// </summary>
1515
public partial class TestRenderer : Renderer, ITestRenderer
1616
{
17-
private readonly object _renderTreeAccessLock = new object();
17+
private readonly object _renderTreeAccessLock = new();
1818
private readonly ILogger _logger;
1919
private readonly IRenderedComponentActivator _activator;
2020
private Exception? _unhandledException;
21-
private readonly Dictionary<int, IRenderedFragmentBase> _renderedComponents = new Dictionary<int, IRenderedFragmentBase>();
21+
private readonly Dictionary<int, IRenderedFragmentBase> _renderedComponents = new();
2222

2323
/// <inheritdoc/>
2424
public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();

src/bunit.web/Diffing/DiffMarkupFormatter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public class DiffMarkupFormatter : PrettyMarkupFormatter, IMarkupFormatter
1313
/// <summary>
1414
/// Gets an instance of the <see cref="DiffMarkupFormatter"/>.
1515
/// </summary>
16-
public new static readonly DiffMarkupFormatter Instance = new DiffMarkupFormatter();
16+
public new static readonly DiffMarkupFormatter Instance = new();
1717

1818
/// <summary>
1919
/// Creates an instance of the <see cref="DiffMarkupFormatter"/>.

src/bunit.web/EventDispatchExtensions/Key.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,7 @@ public static implicit operator KeyboardEventArgs(Key key)
566566
}
567567

568568
// This has to be placed last since it is referencing other static fields, that must be initialized first.
569-
private static readonly Dictionary<(string value, string code), Key> PredefinedKeys = new Dictionary<(string value, string code), Key>
569+
private static readonly Dictionary<(string value, string code), Key> PredefinedKeys = new()
570570
{
571571
{ (Key.Backspace.Value, Key.Backspace.Code), Key.Backspace },
572572
{ (Key.Tab.Value, Key.Tab.Code), Key.Tab },

src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ namespace Bunit
1616
/// </summary>
1717
public static class TriggerEventDispatchExtensions
1818
{
19-
private static readonly HashSet<string> NonBubblingEvents = new HashSet<string> { "onabort", "onblur", "onchange", "onerror", "onfocus", "onload", "onloadend", "onloadstart", "onmouseenter", "onmouseleave", "onprogress", "onreset", "onscroll", "onsubmit", "onunload", "ontoggle", "ondomnodeinsertedintodocument", "ondomnoderemovedfromdocument" };
20-
private static readonly HashSet<string> DisabledEventNames = new HashSet<string> { "onclick", "ondblclick", "onmousedown", "onmousemove", "onmouseup" };
19+
private static readonly HashSet<string> NonBubblingEvents = new() { "onabort", "onblur", "onchange", "onerror", "onfocus", "onload", "onloadend", "onloadstart", "onmouseenter", "onmouseleave", "onprogress", "onreset", "onscroll", "onsubmit", "onunload", "ontoggle", "ondomnodeinsertedintodocument", "ondomnoderemovedfromdocument" };
20+
private static readonly HashSet<string> DisabledEventNames = new() { "onclick", "ondblclick", "onmousedown", "onmousemove", "onmouseup" };
2121

2222
/// <summary>
2323
/// Raises the event <paramref name="eventName"/> on the element <paramref name="element"/>

src/bunit.web/Extensions/TestServiceProviderExtensions.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ public static IServiceCollection AddDefaultTestContextServices(this IServiceColl
2727
services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
2828
services.AddSingleton<AuthenticationStateProvider, PlaceholderAuthenticationStateProvider>();
2929
services.AddSingleton<IAuthorizationService, PlaceholderAuthorizationService>();
30-
services.AddSingleton<IJSRuntime, PlaceholderJSRuntime>();
3130
services.AddSingleton<NavigationManager, PlaceholderNavigationManager>();
3231
services.AddSingleton<HttpClient, PlaceholderHttpClient>();
3332
services.AddSingleton<IStringLocalizer, PlaceholderStringLocalization>();
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.ComponentModel;
4+
using System.Linq;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.JSInterop;
8+
9+
namespace Bunit
10+
{
11+
/// <summary>
12+
/// Represents an bUnit's implementation of Blazor's JSInterop.
13+
/// </summary>
14+
public class BunitJSInterop
15+
{
16+
private readonly Dictionary<string, List<JSRuntimeInvocation>> _invocations = new();
17+
private readonly Dictionary<string, List<object>> _handlers = new();
18+
19+
/// <summary>
20+
/// Gets a dictionary of all <see cref="List{JSRuntimeInvocation}"/> this mock has observed.
21+
/// </summary>
22+
public IReadOnlyDictionary<string, List<JSRuntimeInvocation>> Invocations => _invocations;
23+
24+
/// <summary>
25+
/// Gets or sets whether the mock is running in <see cref="JSRuntimeMode.Loose"/> or
26+
/// <see cref="JSRuntimeMode.Strict"/>.
27+
/// </summary>
28+
public JSRuntimeMode Mode { get; set; }
29+
30+
/// <summary>
31+
/// Gets the mocked <see cref="IJSRuntime"/> instance.
32+
/// </summary>
33+
/// <returns></returns>
34+
public IJSRuntime JSRuntime { get; }
35+
36+
/// <summary>
37+
/// Creates a <see cref="BunitJSInterop"/>.
38+
/// </summary>
39+
public BunitJSInterop()
40+
{
41+
Mode = JSRuntimeMode.Strict;
42+
JSRuntime = new BUnitJSRuntime(this);
43+
}
44+
45+
/// <summary>
46+
/// Configure a catch all JSInterop invocation handler for a specific return type.
47+
/// This will match only on the <typeparamref name="TResult"/>, and any arguments passed to
48+
/// <see cref="IJSRuntime.InvokeAsync{TValue}(string, object[])"/>.
49+
/// </summary>
50+
/// <typeparam name="TResult">The result type of the invocation.</typeparam>
51+
/// <returns>A <see cref="JSRuntimeInvocationHandler{TResult}"/>.</returns>
52+
public JSRuntimeInvocationHandler<TResult> Setup<TResult>()
53+
{
54+
var result = new JSRuntimeInvocationHandler<TResult>(JSRuntimeInvocationHandler<object>.CatchAllIdentifier, _ => true);
55+
56+
AddHandler(result);
57+
58+
return result;
59+
}
60+
61+
/// <summary>
62+
/// Configure a JSInterop invocation handler with the <paramref name="identifier"/> and arguments
63+
/// passing the <paramref name="argumentsMatcher"/> test.
64+
/// </summary>
65+
/// <typeparam name="TResult">The result type of the invocation.</typeparam>
66+
/// <param name="identifier">The identifier to setup a response for.</param>
67+
/// <param name="argumentsMatcher">A matcher that is passed arguments received in invocations to <paramref name="identifier"/>. If it returns true the invocation is matched.</param>
68+
/// <returns>A <see cref="JSRuntimeInvocationHandler{TResult}"/>.</returns>
69+
public JSRuntimeInvocationHandler<TResult> Setup<TResult>(string identifier, Func<IReadOnlyList<object?>, bool> argumentsMatcher)
70+
{
71+
var result = new JSRuntimeInvocationHandler<TResult>(identifier, argumentsMatcher);
72+
73+
AddHandler(result);
74+
75+
return result;
76+
}
77+
78+
/// <summary>
79+
/// Configure a JSInterop invocation handler with the <paramref name="identifier"/> and <paramref name="arguments"/>.
80+
/// </summary>
81+
/// <typeparam name="TResult"></typeparam>
82+
/// <param name="identifier">The identifier to setup a response for.</param>
83+
/// <param name="arguments">The arguments that an invocation to <paramref name="identifier"/> should match.</param>
84+
/// <returns>A <see cref="JSRuntimeInvocationHandler{TResult}"/>.</returns>
85+
public JSRuntimeInvocationHandler<TResult> Setup<TResult>(string identifier, params object[] arguments)
86+
{
87+
return Setup<TResult>(identifier, args => args.SequenceEqual(arguments));
88+
}
89+
90+
/// <summary>
91+
/// Configure a JSInterop invocation handler with the <paramref name="identifier"/> and arguments
92+
/// passing the <paramref name="argumentsMatcher"/> test, that should not receive any result.
93+
/// </summary>
94+
/// <param name="identifier">The identifier to setup a response for.</param>
95+
/// <param name="argumentsMatcher">A matcher that is passed arguments received in invocations to <paramref name="identifier"/>. If it returns true the invocation is matched.</param>
96+
/// <returns>A <see cref="JSRuntimeInvocationHandler"/>.</returns>
97+
public JSRuntimeInvocationHandler SetupVoid(string identifier, Func<IReadOnlyList<object?>, bool> argumentsMatcher)
98+
{
99+
var result = new JSRuntimeInvocationHandler(identifier, argumentsMatcher);
100+
101+
AddHandler(result);
102+
103+
return result;
104+
}
105+
106+
/// <summary>
107+
/// Configure a JSInterop invocation handler with the <paramref name="identifier"/>
108+
/// and <paramref name="arguments"/>, that should not receive any result.
109+
/// </summary>
110+
/// <param name="identifier">The identifier to setup a response for.</param>
111+
/// <param name="arguments">The arguments that an invocation to <paramref name="identifier"/> should match.</param>
112+
/// <returns>A <see cref="JSRuntimeInvocationHandler"/>.</returns>
113+
public JSRuntimeInvocationHandler SetupVoid(string identifier, params object[] arguments)
114+
{
115+
return SetupVoid(identifier, args => args.SequenceEqual(arguments));
116+
}
117+
118+
/// <summary>
119+
/// Configure a catch all JSInterop invocation handler, that should not receive any result.
120+
/// </summary>
121+
/// <returns>A <see cref="JSRuntimeInvocationHandler"/>.</returns>
122+
public JSRuntimeInvocationHandler SetupVoid()
123+
{
124+
var result = new JSRuntimeInvocationHandler(JSRuntimeInvocationHandler<object>.CatchAllIdentifier, _ => true);
125+
126+
AddHandler(result);
127+
128+
return result;
129+
}
130+
131+
/// <summary>
132+
/// Looks through the registered handlers and returns the latest registered that can handle
133+
/// the provided <paramref name="identifier"/> and <paramref name="args"/>, and that
134+
/// will return <typeparamref name="TResult"/>.
135+
/// </summary>
136+
/// <returns>Returns the <see cref="JSRuntimeInvocationHandler{TResult}"/> or null if no one is found.</returns>
137+
public JSRuntimeInvocationHandler<TResult>? TryGetInvokeHandler<TResult>(string identifier, object?[]? args = null)
138+
=> TryGetHandlerFor<TResult>(new JSRuntimeInvocation(identifier, default, args));
139+
140+
/// <summary>
141+
/// Looks through the registered handlers and returns the latest registered that can handle
142+
/// the provided <paramref name="identifier"/> and <paramref name="args"/>, and that returns a "void" result.
143+
/// </summary>
144+
/// <returns>Returns the <see cref="JSRuntimeInvocationHandler"/> or null if no one is found.</returns>
145+
public JSRuntimeInvocationHandler? TryGetInvokeVoidHandler(string identifier, object?[]? args = null)
146+
=> TryGetHandlerFor<object>(new JSRuntimeInvocation(identifier, default, args), x => x.IsVoidResultHandler) as JSRuntimeInvocationHandler;
147+
148+
private void AddHandler<TResult>(JSRuntimeInvocationHandler<TResult> handler)
149+
{
150+
if (!_handlers.ContainsKey(handler.Identifier))
151+
{
152+
_handlers.Add(handler.Identifier, new List<object>());
153+
}
154+
_handlers[handler.Identifier].Add(handler);
155+
}
156+
157+
private void RegisterInvocation(JSRuntimeInvocation invocation)
158+
{
159+
if (!_invocations.ContainsKey(invocation.Identifier))
160+
{
161+
_invocations.Add(invocation.Identifier, new List<JSRuntimeInvocation>());
162+
}
163+
_invocations[invocation.Identifier].Add(invocation);
164+
}
165+
166+
private JSRuntimeInvocationHandler<TResult>? TryGetHandlerFor<TResult>(JSRuntimeInvocation invocation, Predicate<JSRuntimeInvocationHandler<TResult>>? handlerPredicate = null)
167+
{
168+
handlerPredicate ??= _ => true;
169+
JSRuntimeInvocationHandler<TResult>? result = default;
170+
if (_handlers.TryGetValue(invocation.Identifier, out var plannedInvocations))
171+
{
172+
result = plannedInvocations.OfType<JSRuntimeInvocationHandler<TResult>>()
173+
.Where(x => handlerPredicate(x) && x.Matches(invocation)).LastOrDefault();
174+
}
175+
176+
if (result is null && _handlers.TryGetValue(JSRuntimeInvocationHandler<TResult>.CatchAllIdentifier, out var catchAllHandlers))
177+
{
178+
result = catchAllHandlers.OfType<JSRuntimeInvocationHandler<TResult>>().Where(x => handlerPredicate(x)).LastOrDefault();
179+
}
180+
return result;
181+
}
182+
183+
private class BUnitJSRuntime : IJSRuntime
184+
{
185+
private readonly BunitJSInterop _jsInterop;
186+
187+
public BUnitJSRuntime(BunitJSInterop bunitJsInterop)
188+
{
189+
_jsInterop = bunitJsInterop;
190+
}
191+
192+
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
193+
=> InvokeAsync<TValue>(identifier, default, args);
194+
195+
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
196+
{
197+
var invocation = new JSRuntimeInvocation(identifier, cancellationToken, args);
198+
_jsInterop.RegisterInvocation(invocation);
199+
200+
return TryHandlePlannedInvocation<TValue>(invocation) ?? new ValueTask<TValue>(default(TValue)!);
201+
}
202+
203+
private ValueTask<TValue>? TryHandlePlannedInvocation<TValue>(JSRuntimeInvocation invocation)
204+
{
205+
ValueTask<TValue>? result = default;
206+
207+
if (_jsInterop.TryGetHandlerFor<TValue>(invocation) is JSRuntimeInvocationHandler<TValue> handler)
208+
{
209+
var task = handler.RegisterInvocation(invocation);
210+
result = new ValueTask<TValue>(task);
211+
}
212+
else if (_jsInterop.Mode == JSRuntimeMode.Strict)
213+
{
214+
throw new JSRuntimeUnhandledInvocationException(invocation);
215+
}
216+
217+
return result;
218+
}
219+
}
220+
}
221+
}

src/bunit.web/TestDoubles/JSInterop/JSInvokeCountExpectedException.cs renamed to src/bunit.web/JSInterop/JSInvokeCountExpectedException.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
using System;
22
using System.Runtime.Serialization;
33

4-
namespace Bunit.TestDoubles
4+
namespace Bunit
55
{
66
/// <summary>
7-
/// Represents a number of unexpected invocation to a <see cref="MockJSRuntimeInvokeHandler"/>.
7+
/// Represents a number of unexpected invocation to a <see cref="BunitJSInterop"/>.
88
/// </summary>
99
[Serializable]
1010
public sealed class JSInvokeCountExpectedException : Exception

0 commit comments

Comments
 (0)