Skip to content

Commit

Permalink
fix: better handling of disposed event handlers with bubbling events
Browse files Browse the repository at this point in the history
fixes #518 and #517.
  • Loading branch information
egil committed Oct 20, 2021
1 parent b8624d0 commit e5bab37
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 23 deletions.
6 changes: 1 addition & 5 deletions src/bunit.core/Rendering/TestRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,7 @@ public IRenderedComponentBase<TComponent> RenderComponent<TComponent>(ComponentP
}
catch (ArgumentException ex) when (string.Equals(ex.Message, $"There is no event handler associated with this event. EventId: '{eventHandlerId}'. (Parameter 'eventHandlerId')", StringComparison.Ordinal))
{
var betterExceptionMsg = new ArgumentException($"There is no event handler with ID '{eventHandlerId}' associated with the '{fieldInfo.FieldValue}' event " +
"in the current render tree. This can happen, for example, when using cut.FindAll(), and calling event trigger methods " +
"on the found elements after a re-render of the render tree. The workaround is to use re-issue the cut.FindAll() after " +
"each render of a component, this ensures you have the latest version of the render tree and DOM tree available in your test code.", ex);

var betterExceptionMsg = new UnknownEventHandlerIdException(eventHandlerId, fieldInfo, ex);
return Task.FromException(betterExceptionMsg);
}
});
Expand Down
31 changes: 31 additions & 0 deletions src/bunit.core/Rendering/UnknownEventHandlerIdException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using System.Runtime.Serialization;
using Microsoft.AspNetCore.Components.RenderTree;

namespace Bunit.Rendering
{
/// <summary>
/// Represents an exception that is thrown when the Blazor <see cref="Renderer"/>
/// does not have any event handler with the specified a given ID.
/// </summary>
[Serializable]
public sealed class UnknownEventHandlerIdException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="UnknownEventHandlerIdException"/> class.
/// </summary>
public UnknownEventHandlerIdException(ulong eventHandlerId, EventFieldInfo fieldInfo, Exception innerException)
: base(CreateMessage(eventHandlerId, fieldInfo), innerException)
{
}

private UnknownEventHandlerIdException(SerializationInfo serializationInfo, StreamingContext streamingContext)
: base(serializationInfo, streamingContext) { }

private static string CreateMessage(ulong eventHandlerId, EventFieldInfo fieldInfo)
=> $"There is no event handler with ID '{eventHandlerId}' associated with the '{fieldInfo.FieldValue}' event " +
"in the current render tree. This can happen, for example, when using cut.FindAll(), and calling event trigger methods " +
"on the found elements after a re-render of the render tree. The workaround is to use re-issue the cut.FindAll() after " +
"each render of a component, this ensures you have the latest version of the render tree and DOM tree available in your test code.";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,34 @@ private static Task TriggerBubblingEventAsync(ITestRenderer renderer, IElement e
{
var eventAttrName = Htmlizer.ToBlazorAttribute(eventName);
var eventStopPropergationAttrName = $"{eventAttrName}:stoppropagation";
var eventIds = new List<ulong>();
var eventTasks = new List<Task>();

foreach (var candidate in element.GetParentsAndSelf())
{
if (candidate.TryGetEventId(eventAttrName, out var id))
eventIds.Add(id);
{
try
{
var info = new EventFieldInfo() { FieldValue = eventName };
eventTasks.Add(renderer.DispatchEventAsync(id, info, eventArgs));
}
catch (UnknownEventHandlerIdException) when (eventTasks.Count > 0)
{
// Capture and ignore NoEventHandlerException for bubbling events
// if at least one event handler has been triggered without throwing.
}
}

if (candidate.HasAttribute(eventStopPropergationAttrName) || candidate.EventIsDisabled(eventName))
{
break;
}
}

if (eventIds.Count == 0)
if (eventTasks.Count == 0)
throw new MissingEventHandlerException(element, eventName);

var triggerTasks = eventIds.Select(id => renderer.DispatchEventAsync(id, new EventFieldInfo() { FieldValue = eventName }, eventArgs));

return Task.WhenAll(triggerTasks.ToArray());
return Task.WhenAll(eventTasks);
}

private static Task TriggerNonBubblingEventAsync(ITestRenderer renderer, IElement element, string eventName, EventArgs eventArgs)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div @onclick="() => TopDivClicked = true">
@if (!BtnClicked)
{
<div @onclick="() => MiddleDivClicked = true">
<button @onclick="() => BtnClicked = true">Click me!</button>
</div>
}
</div>
@code {
public bool BtnClicked { get; private set; } = false;
public bool MiddleDivClicked { get; private set; } = false;
public bool TopDivClicked { get; private set; } = false;
}
19 changes: 19 additions & 0 deletions tests/bunit.testassets/SampleComponents/BubbleEventsThrows.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<div @onclick="() => TopDivClicked = true">
<div @onclick=@MiddleDivHandler>
<button @onclick="() => BtnClicked = true">Click me!</button>
</div>
</div>

<p>@BtnClicked</p>
<p>@MiddleDivClicked</p>
<p>@TopDivClicked</p>

@code {
[Parameter] public string ExceptionMessage { get; set; }

public bool BtnClicked { get; private set; } = false;
public bool MiddleDivClicked { get; private set; } = false;
public bool TopDivClicked { get; private set; } = false;

private void MiddleDivHandler() => throw new Exception(ExceptionMessage);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<button id="first" @onclick="() => visible = false"></button>
@if (visible)
{
<button id="second" @onclick="() => SecondButtonClicked = true"></button>
}
@code {
private bool visible = true;
public bool SecondButtonClicked { get; private set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<button @onclick="Handler"></button>
@code {
[Parameter] public string ExceptionMessage { get; set; }
private void Handler() => throw new Exception(ExceptionMessage);
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using AngleSharp;
using AngleSharp.Dom;
using AutoFixture.Xunit2;
using Bunit.Rendering;
using Bunit.TestAssets.SampleComponents;
using Moq;
Expand Down Expand Up @@ -175,18 +177,6 @@ public async Task Test114(
cut.Instance.GrandParentTriggerCount.ShouldBe(1);
}


[Fact(DisplayName = "TriggerEventAsync throws when invoked with an unknown event handler ID")]
public void Test115()
{
var eventName = "onclick";
var cut = RenderComponent<Wrapper>(ps => ps.AddChildContent("<div />"));
var div = cut.Find("div");
div.SetAttribute(Htmlizer.ToBlazorAttribute(eventName), "42");

Should.Throw<ArgumentException>(() => div.TriggerEventAsync(eventName, EventArgs.Empty));
}

#if NET6_0_OR_GREATER
[Fact(DisplayName = "TriggerEvent can trigger custom events")]
public void Test201()
Expand All @@ -202,5 +192,54 @@ public void Test201()
cut.Find("p:last-child").MarkupMatches("<p>You pasted: FOO</p>");
}
#endif

[Fact(DisplayName = "TriggerEventAsync throws NoEventHandlerException when invoked with an unknown event handler ID")]
public void Test300()
{
var cut = RenderComponent<ClickRemovesEventHandler>();
var buttons = cut.FindAll("button");
buttons[0].Click();

Should.Throw<UnknownEventHandlerIdException>(() => buttons[1].Click());
}

[Fact(DisplayName = "Removed bubbled event handled NoEventHandlerException are ignored")]
public void Test301()
{
var cut = RenderComponent<BubbleEventsRemoveTriggers>();

cut.Find("button").Click();

// When middle div clicked event handlers is disposed, the
// NoEventHandlerException is ignored and the top div clicked event
// handler is still invoked.
cut.Instance.BtnClicked.ShouldBeTrue();
cut.Instance.MiddleDivClicked.ShouldBeFalse();
cut.Instance.TopDivClicked.ShouldBeTrue();
}

[Theory(DisplayName = "When bubbling event throws, no other event handlers are triggered")]
[AutoData]
public void Test302(string exceptionMessage)
{
var cut = RenderComponent<BubbleEventsThrows>(ps => ps.Add(p => p.ExceptionMessage, exceptionMessage));

Should.Throw<Exception>(() => cut.Find("button").Click())
.Message.ShouldBe(exceptionMessage);

cut.Instance.BtnClicked.ShouldBeTrue();
cut.Instance.MiddleDivClicked.ShouldBeFalse();
cut.Instance.TopDivClicked.ShouldBeFalse();
}

[Theory(DisplayName = "When event handler throws, the exception is passed up to test")]
[AutoData]
public void Test303(string exceptionMessage)
{
var cut = RenderComponent<EventHandlerThrows>(ps => ps.Add(p => p.ExceptionMessage, exceptionMessage));

Should.Throw<Exception>(() => cut.Find("button").Click())
.Message.ShouldBe(exceptionMessage);
}
}
}

0 comments on commit e5bab37

Please sign in to comment.