From 5085ebe2a24b502b4bd08ec20a525ceffad1eb90 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 20 May 2022 22:36:50 +0000 Subject: [PATCH 01/21] build: only run release-preview on main --- .github/workflows/release-preview.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index c0d506730..0b1b78b96 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -4,7 +4,8 @@ concurrency: 'release-preview' on: workflow_run: workflows: [ 'verification' ] - types: [completed] + types: [completed] + branches: [main] workflow_dispatch: inputs: From 85ce744f3bc5cdccdd61d819376c806f2abc76fa Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 20 May 2022 17:21:52 +0000 Subject: [PATCH 02/21] fix: race condition between renders, FindComponents, and WaitForHelper fixes #577. The problem is that FindComponents traverses down the render tree when invoked, and this ensures that no renders happens while it does so, without using locks like previous, which could result in deadlocks. fix: aways wrap FindComponentsInternal in Dispatcher fix: optimize wait for logging fix: ensure failure tasks in WaitForHelper run on Renderer schedular --- CHANGELOG.md | 4 + .../WaitForHelpers/WaitForHelper.cs | 209 +++++++++--------- .../WaitForHelperLoggerExtensions.cs | 42 +++- src/bunit.core/Rendering/TestRenderer.cs | 71 +++--- ...eredFragmentWaitForHelperExtensionsTest.cs | 1 - .../TestContextBaseTest.net5.cs | 16 +- .../TaskAssertionExtensions.cs | 10 + .../BlazorE2E/ComponentRenderingTest.cs | 4 +- ...mentWaitForElementsHelperExtensionsTest.cs | 1 - 9 files changed, 196 insertions(+), 162 deletions(-) create mode 100644 tests/bunit.testassets/AssertExtensions/TaskAssertionExtensions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 812e2bce5..7a1abf7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### Fixes + +- A race condition existed between `WaitForState` / `WaitForAssertion` and `FindComponents`, if the first used the latter. Reported by [@rmihael](https://github.com/rmihael), [@SviatoslavK](https://github.com/SviatoslavK), and [@RaphaelMarcouxCTRL](https://github.com/RaphaelMarcouxCTRL). Fixed by [@egil](https://github.com/egil) and [@linkdotnet](https://github.com/linkdotnet). + ## [1.8.15] - 2022-05-19 ### Added diff --git a/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs b/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs index c9d759848..6e3e8d579 100644 --- a/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs +++ b/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs @@ -9,8 +9,6 @@ namespace Bunit.Extensions.WaitForHelpers; /// public abstract class WaitForHelper : IDisposable { - private readonly object lockObject = new(); - private readonly Timer timer; private readonly TaskCompletionSource checkPassedCompletionSource; private readonly Func<(bool CheckPassed, T Content)> completeChecker; private readonly IRenderedFragmentBase renderedFragment; @@ -40,143 +38,154 @@ public abstract class WaitForHelper : IDisposable /// public Task WaitTask { get; } - /// /// Initializes a new instance of the class. /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Using x.Result inside a ContinueWith is safe.")] - protected WaitForHelper(IRenderedFragmentBase renderedFragment, Func<(bool CheckPassed, T Content)> completeChecker, TimeSpan? timeout = null) + protected WaitForHelper( + IRenderedFragmentBase renderedFragment, + Func<(bool CheckPassed, T Content)> completeChecker, + TimeSpan? timeout = null) { this.renderedFragment = renderedFragment ?? throw new ArgumentNullException(nameof(renderedFragment)); this.completeChecker = completeChecker ?? throw new ArgumentNullException(nameof(completeChecker)); + logger = renderedFragment.Services.CreateLogger>(); + checkPassedCompletionSource = new TaskCompletionSource(); + WaitTask = CreateWaitTask(renderedFragment, timeout); - var renderer = renderedFragment.Services.GetRequiredService(); - var renderException = renderer - .UnhandledException - .ContinueWith(x => Task.FromException(x.Result), CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current) - .Unwrap(); + InitializeWaiting(); + } - checkPassedCompletionSource = new TaskCompletionSource(); - WaitTask = Task.WhenAny(checkPassedCompletionSource.Task, renderException).Unwrap(); + /// + /// Disposes the wait helper and cancels the any ongoing waiting, if it is not + /// already in one of the other completed states. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } - timer = new Timer(OnTimeout, this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + /// + /// Disposes of the wait task and related logic. + /// + /// + /// The disposing parameter should be false when called from a finalizer, and true when called from the + /// method. In other words, it is true when deterministically called and false when non-deterministically called. + /// + /// Set to true if called from , false if called from a finalizer.f. + protected virtual void Dispose(bool disposing) + { + if (isDisposed || !disposing) + return; + + isDisposed = true; + checkPassedCompletionSource.TrySetCanceled(); + renderedFragment.OnAfterRender -= OnAfterRender; + logger.LogWaiterDisposed(renderedFragment.ComponentId); + } + private void InitializeWaiting() + { if (!WaitTask.IsCompleted) { + var renderCountAtSubscribeTime = renderedFragment.RenderCount; + + // Before subscribing to renderedFragment.OnAfterRender, + // we need to make sure that the desired state has not already been reached. OnAfterRender(this, EventArgs.Empty); - this.renderedFragment.OnAfterRender += OnAfterRender; - OnAfterRender(this, EventArgs.Empty); - StartTimer(timeout); + + SubscribeToOnAfterRender(); + + // If the render count from before subscribing has changes + // till now, we need to do trigger another check, since + // the render may have happened asynchronously and before + // the subscription was set up. + if (renderCountAtSubscribeTime < renderedFragment.RenderCount) + { + OnAfterRender(this, EventArgs.Empty); + } } } - private void StartTimer(TimeSpan? timeout) + private Task CreateWaitTask(IRenderedFragmentBase renderedFragment, TimeSpan? timeout) { - if (isDisposed) - return; + var renderer = renderedFragment.Services.GetRequiredService(); - lock (lockObject) + // Two to failure conditions, that the renderer captures an unhandled + // exception from a component or itself, or that the timeout is reached, + // are executed on the renderes schedular, to ensure that OnAfterRender + // and the continuations does not happen at the same time. + var failureTask = renderer.Dispatcher.InvokeAsync(() => { - if (isDisposed) - return; - - timer.Change(GetRuntimeTimeout(timeout), Timeout.InfiniteTimeSpan); - } + var taskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); + + var renderException = renderer + .UnhandledException + .ContinueWith( + x => Task.FromException(x.Result), + CancellationToken.None, + TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously, + taskScheduler); + + var timeoutTask = Task.Delay(GetRuntimeTimeout(timeout)) + .ContinueWith( + x => + { + logger.LogWaiterTimedOut(renderedFragment.ComponentId); + return Task.FromException(new WaitForFailedException(TimeoutErrorMessage, capturedException)); + }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously, + taskScheduler); + + return Task.WhenAny(renderException, timeoutTask).Unwrap(); + }).Unwrap(); + + return Task.WhenAny(failureTask, checkPassedCompletionSource.Task).Unwrap(); } private void OnAfterRender(object? sender, EventArgs args) { - if (isDisposed) + if (isDisposed || WaitTask.IsCompleted) return; - lock (lockObject) + try { - if (isDisposed) - return; + logger.LogCheckingWaitCondition(renderedFragment.ComponentId); - try + var checkResult = completeChecker(); + if (checkResult.CheckPassed) { - logger.LogCheckingWaitCondition(renderedFragment.ComponentId); - - var checkResult = completeChecker(); - if (checkResult.CheckPassed) - { - checkPassedCompletionSource.TrySetResult(checkResult.Content); - logger.LogCheckCompleted(renderedFragment.ComponentId); - Dispose(); - } - else - { - logger.LogCheckFailed(renderedFragment.ComponentId); - } + checkPassedCompletionSource.TrySetResult(checkResult.Content); + logger.LogCheckCompleted(renderedFragment.ComponentId); + Dispose(); } - catch (Exception ex) + else { - capturedException = ex; - logger.LogCheckThrow(renderedFragment.ComponentId, ex); - - if (StopWaitingOnCheckException) - { - checkPassedCompletionSource.TrySetException(new WaitForFailedException(CheckThrowErrorMessage, capturedException)); - Dispose(); - } + logger.LogCheckFailed(renderedFragment.ComponentId); } } - } - - private void OnTimeout(object? state) - { - if (isDisposed) - return; - - lock (lockObject) + catch (Exception ex) { - if (isDisposed) - return; - - logger.LogWaiterTimedOut(renderedFragment.ComponentId); + capturedException = ex; + logger.LogCheckThrow(renderedFragment.ComponentId, ex); - checkPassedCompletionSource.TrySetException(new WaitForFailedException(TimeoutErrorMessage, capturedException)); - - Dispose(); + if (StopWaitingOnCheckException) + { + checkPassedCompletionSource.TrySetException(new WaitForFailedException(CheckThrowErrorMessage, capturedException)); + Dispose(); + } } } - /// - /// Disposes the wait helper and cancels the any ongoing waiting, if it is not - /// already in one of the other completed states. - /// - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - /// - /// Disposes of the wait task and related logic. - /// - /// - /// The disposing parameter should be false when called from a finalizer, and true when called from the - /// method. In other words, it is true when deterministically called and false when non-deterministically called. - /// - /// Set to true if called from , false if called from a finalizer.f. - protected virtual void Dispose(bool disposing) + private void SubscribeToOnAfterRender() { - if (isDisposed || !disposing) - return; - - lock (lockObject) - { - if (isDisposed) - return; - - isDisposed = true; - renderedFragment.OnAfterRender -= OnAfterRender; - timer.Dispose(); - checkPassedCompletionSource.TrySetCanceled(); - logger.LogWaiterDisposed(renderedFragment.ComponentId); - } + // There might not be a need to subscribe if the WaitTask has already + // been completed, perhaps due to an unhandled exception from the + // renderer or from the initial check by the checker. + if (!isDisposed && !WaitTask.IsCompleted) + renderedFragment.OnAfterRender += OnAfterRender; } private static TimeSpan GetRuntimeTimeout(TimeSpan? timeout) diff --git a/src/bunit.core/Extensions/WaitForHelpers/WaitForHelperLoggerExtensions.cs b/src/bunit.core/Extensions/WaitForHelpers/WaitForHelperLoggerExtensions.cs index b81570116..53e35419b 100644 --- a/src/bunit.core/Extensions/WaitForHelpers/WaitForHelperLoggerExtensions.cs +++ b/src/bunit.core/Extensions/WaitForHelpers/WaitForHelperLoggerExtensions.cs @@ -23,20 +23,50 @@ private static readonly Action CheckThrow = LoggerMessage.Define(LogLevel.Debug, new EventId(20, "OnTimeout"), "The waiter for component {Id} disposed."); internal static void LogCheckingWaitCondition(this ILogger> logger, int componentId) - => CheckingWaitCondition(logger, componentId, null); + { + if (logger.IsEnabled(LogLevel.Debug)) + { + CheckingWaitCondition(logger, componentId, null); + } + } internal static void LogCheckCompleted(this ILogger> logger, int componentId) - => CheckCompleted(logger, componentId, null); + { + if (logger.IsEnabled(LogLevel.Debug)) + { + CheckCompleted(logger, componentId, null); + } + } internal static void LogCheckFailed(this ILogger> logger, int componentId) - => CheckFailed(logger, componentId, null); + { + if (logger.IsEnabled(LogLevel.Debug)) + { + CheckFailed(logger, componentId, null); + } + } internal static void LogCheckThrow(this ILogger> logger, int componentId, Exception exception) - => CheckThrow(logger, componentId, exception); + { + if (logger.IsEnabled(LogLevel.Debug)) + { + CheckThrow(logger, componentId, exception); + } + } internal static void LogWaiterTimedOut(this ILogger> logger, int componentId) - => WaiterTimedOut(logger, componentId, null); + { + if (logger.IsEnabled(LogLevel.Debug)) + { + WaiterTimedOut(logger, componentId, null); + } + } internal static void LogWaiterDisposed(this ILogger> logger, int componentId) - => WaiterDisposed(logger, componentId, null); + { + if (logger.IsEnabled(LogLevel.Debug)) + { + WaiterDisposed(logger, componentId, null); + } + } } diff --git a/src/bunit.core/Rendering/TestRenderer.cs b/src/bunit.core/Rendering/TestRenderer.cs index 04992e0d7..817b3cf16 100644 --- a/src/bunit.core/Rendering/TestRenderer.cs +++ b/src/bunit.core/Rendering/TestRenderer.cs @@ -8,7 +8,6 @@ namespace Bunit.Rendering; /// public class TestRenderer : Renderer, ITestRenderer { - private readonly object renderTreeAccessLock = new(); private readonly Dictionary renderedComponents = new(); private readonly List rootComponents = new(); private readonly ILogger logger; @@ -143,22 +142,6 @@ public void DisposeComponents() AssertNoUnhandledExceptions(); } - /// - protected override void ProcessPendingRender() - { - // the lock is in place to avoid a race condition between - // the dispatchers thread and the test frameworks thread, - // where one will read the current render tree (find components) - // while the other thread (the renderer) updates the - // render tree. - lock (renderTreeAccessLock) - { - logger.LogProcessingPendingRenders(); - - base.ProcessPendingRender(); - } - } - /// protected override void HandleException(Exception exception) { @@ -274,45 +257,45 @@ private IReadOnlyList> FindComponents>(); - var framesCollection = new RenderTreeFrameDictionary(); - - // the lock is in place to avoid a race condition between - // the dispatchers thread and the test frameworks thread, - // where one will read the current render tree (this method) - // while the other thread (the renderer) updates the - // render tree. - lock (renderTreeAccessLock) + // Ensure FindComponents runs on the same thread as the renderer, + // and that the renderer does not perform any renders while + // FindComponents is traversing the current render tree. + // Without this, the render tree could change while FindComponentsInternal + // is traversing down the render tree, with indeterministic as a results. + return Dispatcher.InvokeAsync(() => { - FindComponentsInternal(parentComponent.ComponentId); - } + var result = new List>(); + var framesCollection = new RenderTreeFrameDictionary(); - return result; + FindComponentsInRenderTree(parentComponent.ComponentId); - void FindComponentsInternal(int componentId) - { - var frames = GetOrLoadRenderTreeFrame(framesCollection, componentId); + return result; - for (var i = 0; i < frames.Count; i++) + void FindComponentsInRenderTree(int componentId) { - ref var frame = ref frames.Array[i]; - if (frame.FrameType == RenderTreeFrameType.Component) + var frames = GetOrLoadRenderTreeFrame(framesCollection, componentId); + + for (var i = 0; i < frames.Count; i++) { - if (frame.Component is TComponent component) + ref var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Component) { - result.Add(GetOrCreateRenderedComponent(framesCollection, frame.ComponentId, component)); + if (frame.Component is TComponent component) + { + result.Add(GetOrCreateRenderedComponent(framesCollection, frame.ComponentId, component)); + + if (result.Count == resultLimit) + return; + } + + FindComponentsInRenderTree(frame.ComponentId); if (result.Count == resultLimit) return; } - - FindComponentsInternal(frame.ComponentId); - - if (result.Count == resultLimit) - return; } } - } + }).GetAwaiter().GetResult(); } IRenderedComponentBase GetOrCreateRenderedComponent(RenderTreeFrameDictionary framesCollection, int componentId, TComponent component) @@ -385,4 +368,4 @@ private void AssertNoUnhandledExceptions() } } } -} \ No newline at end of file +} diff --git a/tests/bunit.core.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensionsTest.cs b/tests/bunit.core.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensionsTest.cs index 6848e91a2..6a19a20e2 100644 --- a/tests/bunit.core.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensionsTest.cs +++ b/tests/bunit.core.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensionsTest.cs @@ -37,7 +37,6 @@ public void Test011() cut.WaitForAssertion(() => cut.Markup.ShouldBeEmpty(), TimeSpan.FromMilliseconds(10))); expected.Message.ShouldBe(WaitForAssertionHelper.TimeoutMessage); - expected.InnerException.ShouldBeOfType(); } [Fact(DisplayName = "WaitForState throws exception after timeout")] diff --git a/tests/bunit.core.tests/TestContextBaseTest.net5.cs b/tests/bunit.core.tests/TestContextBaseTest.net5.cs index 1c9ceb850..417156fa8 100644 --- a/tests/bunit.core.tests/TestContextBaseTest.net5.cs +++ b/tests/bunit.core.tests/TestContextBaseTest.net5.cs @@ -1,5 +1,6 @@ #if NET5_0_OR_GREATER +using Bunit.Rendering; using Bunit.TestAssets.SampleComponents.DisposeComponents; namespace Bunit; @@ -61,14 +62,11 @@ public async Task Test201() public async Task Test202() { var cut = RenderComponent(); - var instance = cut.Instance; + var wasDisposedTask = cut.Instance.DisposedTask; DisposeComponents(); - // Windows timer resolution is around 15ms therefore we want to have a higher value than the test - // itself to prohibit flakiness - await Task.Delay(50); - instance.WasDisposed.ShouldBeTrue(); + await wasDisposedTask.ShouldCompleteWithin(TimeSpan.FromMilliseconds(100)); } [Fact(DisplayName = "DisposeComponents should dispose components added via ComponentFactory")] @@ -151,12 +149,14 @@ public async ValueTask DisposeAsync() private sealed class AsyncDisposableComponent : ComponentBase, IAsyncDisposable { - public bool WasDisposed { get; private set; } + private readonly TaskCompletionSource tsc = new(); + + public Task DisposedTask => tsc.Task; public async ValueTask DisposeAsync() { - await Task.Delay(30); - WasDisposed = true; + await Task.Delay(10); + tsc.SetResult(); } } diff --git a/tests/bunit.testassets/AssertExtensions/TaskAssertionExtensions.cs b/tests/bunit.testassets/AssertExtensions/TaskAssertionExtensions.cs new file mode 100644 index 000000000..934fc007a --- /dev/null +++ b/tests/bunit.testassets/AssertExtensions/TaskAssertionExtensions.cs @@ -0,0 +1,10 @@ +namespace Bunit; + +public static class TaskAssertionExtensions +{ + public static async Task ShouldCompleteWithin(this Task task, TimeSpan timeout) + { + if (task != await Task.WhenAny(task, Task.Delay(timeout))) + throw new TimeoutException(); + } +} diff --git a/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs b/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs index a02da3e8d..a2322c6c8 100644 --- a/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs +++ b/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs @@ -529,7 +529,7 @@ public void CanAcceptSimultaneousRenderRequests() timeout: TimeSpan.FromMilliseconds(2000)); } - [Fact(Skip = "Fails on Linux from time to time. Disabled for now.")] + [Fact] public void CanDispatchRenderToSyncContext() { var cut = RenderComponent(); @@ -540,7 +540,7 @@ public void CanDispatchRenderToSyncContext() cut.WaitForAssertion(() => Assert.Equal("Success (completed synchronously)", result.TextContent.Trim())); } - [Fact(Skip = "Fails on Linux from time to time. Disabled for now.")] + [Fact] public void CanDoubleDispatchRenderToSyncContext() { var cut = RenderComponent(); diff --git a/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensionsTest.cs b/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensionsTest.cs index f71556126..e9b7b05f7 100644 --- a/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensionsTest.cs +++ b/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensionsTest.cs @@ -30,7 +30,6 @@ public void Test002() cut.WaitForElement("#notHereElm", TimeSpan.FromMilliseconds(10))); expected.Message.ShouldBe(WaitForElementHelper.TimeoutBeforeFoundMessage); - expected.InnerException.ShouldBeOfType(); } [Fact(DisplayName = "WaitForElements waits until cssSelector returns at least one element")] From cf1724e37ed80092c4704cd433141719f2f3c373 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 May 2022 17:54:50 +0200 Subject: [PATCH 03/21] build(deps): bump Nerdbank.GitVersioning from 3.5.104 to 3.5.107 (#736) Bumps [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) from 3.5.104 to 3.5.107. - [Release notes](https://github.com/dotnet/Nerdbank.GitVersioning/releases) - [Commits](https://github.com/dotnet/Nerdbank.GitVersioning/compare/v3.5.104...v3.5.107) --- updated-dependencies: - dependency-name: Nerdbank.GitVersioning dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 976c71913..cc9962361 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -43,7 +43,7 @@ - + From 045fcd0a0c4de7058b93f20bfad3565619a29f3c Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Wed, 25 May 2022 23:01:55 +0000 Subject: [PATCH 04/21] refactor: to file scope namespace --- .../GeneralEventDispatchExtensionsTest.cs | 418 +++++++++--------- 1 file changed, 211 insertions(+), 207 deletions(-) diff --git a/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs b/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs index e3b9fd680..748cdfce5 100644 --- a/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs +++ b/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs @@ -2,266 +2,270 @@ using AngleSharp.Dom; using Bunit.Rendering; -namespace Bunit +namespace Bunit; + +public class GeneralEventDispatchExtensionsTest : EventDispatchExtensionsTest { - public class GeneralEventDispatchExtensionsTest : EventDispatchExtensionsTest - { - protected override string ElementName => "p"; + protected override string ElementName => "p"; - [Theory(DisplayName = "General events are raised correctly through helpers")] - [MemberData(nameof(GetEventHelperMethods), typeof(GeneralEventDispatchExtensions))] - public void CanRaiseEvents(MethodInfo helper) - { - if (helper is null) - throw new ArgumentNullException(nameof(helper)); + [Theory(DisplayName = "General events are raised correctly through helpers")] + [MemberData(nameof(GetEventHelperMethods), typeof(GeneralEventDispatchExtensions))] + public void CanRaiseEvents(MethodInfo helper) + { + if (helper is null) + throw new ArgumentNullException(nameof(helper)); - if (helper.Name == nameof(TriggerEventDispatchExtensions.TriggerEventAsync)) - return; + if (helper.Name == nameof(TriggerEventDispatchExtensions.TriggerEventAsync)) + return; - VerifyEventRaisesCorrectly(helper, EventArgs.Empty); - } + VerifyEventRaisesCorrectly(helper, EventArgs.Empty); + } - [Fact(DisplayName = "TriggerEventAsync throws element is null")] - public void Test001() - { - IElement elm = default!; - Should.Throw(() => elm.TriggerEventAsync(string.Empty, EventArgs.Empty)) - .ParamName.ShouldBe("element"); - } + [Fact(DisplayName = "TriggerEventAsync throws element is null")] + public void Test001() + { + IElement elm = default!; + Should.Throw(() => elm.TriggerEventAsync(string.Empty, EventArgs.Empty)) + .ParamName.ShouldBe("element"); + } - [Fact(DisplayName = "TriggerEventAsync throws if element does not contain an attribute with the blazor event-name")] - public void Test002() - { - var cut = RenderComponent(); + [Fact(DisplayName = "TriggerEventAsync throws if element does not contain an attribute with the blazor event-name")] + public void Test002() + { + var cut = RenderComponent(); - Should.Throw(() => cut.Find("h1").Click()); - } + Should.Throw(() => cut.Find("h1").Click()); + } - [Fact(DisplayName = "TriggerEventAsync throws if element was not rendered through blazor (has a TestRendere in its context)")] - public void Test003() - { - var elmMock = new Mock(); - var docMock = new Mock(); - var ctxMock = new Mock(); + [Fact(DisplayName = "TriggerEventAsync throws if element was not rendered through blazor (has a TestRendere in its context)")] + public void Test003() + { + var elmMock = new Mock(); + var docMock = new Mock(); + var ctxMock = new Mock(); - elmMock.Setup(x => x.GetAttribute(It.IsAny())).Returns("1"); - elmMock.SetupGet(x => x.Owner).Returns(docMock.Object); - docMock.SetupGet(x => x.Context).Returns(ctxMock.Object); - ctxMock.Setup(x => x.GetService()).Returns(() => null!); + elmMock.Setup(x => x.GetAttribute(It.IsAny())).Returns("1"); + elmMock.SetupGet(x => x.Owner).Returns(docMock.Object); + docMock.SetupGet(x => x.Context).Returns(ctxMock.Object); + ctxMock.Setup(x => x.GetService()).Returns(() => null!); - Should.Throw(() => elmMock.Object.TriggerEventAsync("click", EventArgs.Empty)); - } + Should.Throw(() => elmMock.Object.TriggerEventAsync("click", EventArgs.Empty)); + } - [Fact(DisplayName = "When clicking on an element with an event handler, " + - "event handlers higher up the DOM tree is also triggered")] - public void Test100() - { - var cut = RenderComponent(); + [Fact(DisplayName = "When clicking on an element with an event handler, " + + "event handlers higher up the DOM tree is also triggered")] + public void Test100() + { + var cut = RenderComponent(); - cut.Find("span").Click(); + cut.Find("span").Click(); - cut.Instance.SpanClickCount.ShouldBe(1); - cut.Instance.HeaderClickCount.ShouldBe(1); - } + cut.Instance.SpanClickCount.ShouldBe(1); + cut.Instance.HeaderClickCount.ShouldBe(1); + } - [Fact(DisplayName = "When clicking on an element without an event handler attached, " + - "event handlers higher up the DOM tree is triggered")] - public void Test101() - { - var cut = RenderComponent(); + [Fact(DisplayName = "When clicking on an element without an event handler attached, " + + "event handlers higher up the DOM tree is triggered")] + public void Test101() + { + var cut = RenderComponent(); - cut.Find("button").Click(); + cut.Find("button").Click(); - cut.Instance.SpanClickCount.ShouldBe(0); - cut.Instance.HeaderClickCount.ShouldBe(1); - } + cut.Instance.SpanClickCount.ShouldBe(0); + cut.Instance.HeaderClickCount.ShouldBe(1); + } - [Theory(DisplayName = "When clicking element with non-bubbling events, the event does not bubble")] - [InlineData("onabort")] - [InlineData("onblur")] - [InlineData("onchange")] - [InlineData("onerror")] - [InlineData("onfocus")] - [InlineData("onload")] - [InlineData("onloadend")] - [InlineData("onloadstart")] - [InlineData("onmouseenter")] - [InlineData("onmouseleave")] - [InlineData("onprogress")] - [InlineData("onreset")] - [InlineData("onscroll")] - [InlineData("onunload")] - [InlineData("ontoggle")] - [InlineData("onDOMNodeInsertedIntoDocument")] - [InlineData("onDOMNodeRemovedFromDocument")] - [InlineData("oninvalid")] - [InlineData("onpointerleave")] - [InlineData("onpointerenter")] - [InlineData("onselectionchange")] - public async Task Test110(string eventName) - { - var cut = RenderComponent(ps => ps.Add(p => p.EventName, eventName)); + [Theory(DisplayName = "When clicking element with non-bubbling events, the event does not bubble")] + [InlineData("onabort")] + [InlineData("onblur")] + [InlineData("onchange")] + [InlineData("onerror")] + [InlineData("onfocus")] + [InlineData("onload")] + [InlineData("onloadend")] + [InlineData("onloadstart")] + [InlineData("onmouseenter")] + [InlineData("onmouseleave")] + [InlineData("onprogress")] + [InlineData("onreset")] + [InlineData("onscroll")] + [InlineData("onunload")] + [InlineData("ontoggle")] + [InlineData("onDOMNodeInsertedIntoDocument")] + [InlineData("onDOMNodeRemovedFromDocument")] + [InlineData("oninvalid")] + [InlineData("onpointerleave")] + [InlineData("onpointerenter")] + [InlineData("onselectionchange")] + public async Task Test110(string eventName) + { + var cut = RenderComponent(ps => ps.Add(p => p.EventName, eventName)); - await cut.Find("#child").TriggerEventAsync(eventName, EventArgs.Empty); + await cut.Find("#child").TriggerEventAsync(eventName, EventArgs.Empty); - cut.Instance.ChildTriggerCount.ShouldBe(1); - cut.Instance.ParentTriggerCount.ShouldBe(0); - cut.Instance.GrandParentTriggerCount.ShouldBe(0); - } + cut.Instance.ChildTriggerCount.ShouldBe(1); + cut.Instance.ParentTriggerCount.ShouldBe(0); + cut.Instance.GrandParentTriggerCount.ShouldBe(0); + } - [Fact(DisplayName = "When event has StopPropergation modifier, events does not bubble from target")] - public async Task Test111() - { - var cut = RenderComponent(ps => ps - .Add(p => p.EventName, "onclick") - .Add(p => p.ChildStopPropergation, true)); + [Fact(DisplayName = "When event has StopPropergation modifier, events does not bubble from target")] + public async Task Test111() + { + var cut = RenderComponent(ps => ps + .Add(p => p.EventName, "onclick") + .Add(p => p.ChildStopPropergation, true)); - await cut.Find("#child").TriggerEventAsync("onclick", EventArgs.Empty); + await cut.Find("#child").TriggerEventAsync("onclick", EventArgs.Empty); - cut.Instance.ChildTriggerCount.ShouldBe(1); - cut.Instance.ParentTriggerCount.ShouldBe(0); - cut.Instance.GrandParentTriggerCount.ShouldBe(0); - } + cut.Instance.ChildTriggerCount.ShouldBe(1); + cut.Instance.ParentTriggerCount.ShouldBe(0); + cut.Instance.GrandParentTriggerCount.ShouldBe(0); + } - [Fact(DisplayName = "When event has StopPropergation modifier, events does not bubble from parent of target")] - public async Task Test112() - { - var cut = RenderComponent(ps => ps - .Add(p => p.EventName, "onclick") - .Add(p => p.ParentStopPropergation, true)); + [Fact(DisplayName = "When event has StopPropergation modifier, events does not bubble from parent of target")] + public async Task Test112() + { + var cut = RenderComponent(ps => ps + .Add(p => p.EventName, "onclick") + .Add(p => p.ParentStopPropergation, true)); - await cut.Find("#child").TriggerEventAsync("onclick", EventArgs.Empty); + await cut.Find("#child").TriggerEventAsync("onclick", EventArgs.Empty); - cut.Instance.ChildTriggerCount.ShouldBe(1); - cut.Instance.ParentTriggerCount.ShouldBe(1); - cut.Instance.GrandParentTriggerCount.ShouldBe(0); - } + cut.Instance.ChildTriggerCount.ShouldBe(1); + cut.Instance.ParentTriggerCount.ShouldBe(1); + cut.Instance.GrandParentTriggerCount.ShouldBe(0); + } - [Theory(DisplayName = "Disabled input elements does not bubble for event type"), PairwiseData] - public async Task Test113( - [CombinatorialValues("onclick", "ondblclick", "onmousedown", "onmousemove", "onmouseup")] string eventName, - [CombinatorialValues("button", "input", "textarea", "select")] string elementType) - { - var cut = RenderComponent(ps => ps - .Add(p => p.EventName, eventName) - .Add(p => p.ChildElementType, elementType) - .Add(p => p.ChildElementDisabled, true)); + [Theory(DisplayName = "Disabled input elements does not bubble for event type"), PairwiseData] + public async Task Test113( + [CombinatorialValues("onclick", "ondblclick", "onmousedown", "onmousemove", "onmouseup")] string eventName, + [CombinatorialValues("button", "input", "textarea", "select")] string elementType) + { + var cut = RenderComponent(ps => ps + .Add(p => p.EventName, eventName) + .Add(p => p.ChildElementType, elementType) + .Add(p => p.ChildElementDisabled, true)); - await cut.Find("#child").TriggerEventAsync(eventName, EventArgs.Empty); + await cut.Find("#child").TriggerEventAsync(eventName, EventArgs.Empty); - cut.Instance.ChildTriggerCount.ShouldBe(1); - cut.Instance.ParentTriggerCount.ShouldBe(0); - cut.Instance.GrandParentTriggerCount.ShouldBe(0); - } + cut.Instance.ChildTriggerCount.ShouldBe(1); + cut.Instance.ParentTriggerCount.ShouldBe(0); + cut.Instance.GrandParentTriggerCount.ShouldBe(0); + } - [Theory(DisplayName = "Enabled input elements does not bubble for event type"), PairwiseData] - public async Task Test114( - [CombinatorialValues("onclick", "ondblclick", "onmousedown", "onmousemove", "onmouseup")] string eventName, - [CombinatorialValues("button", "input", "textarea", "select")] string elementType) - { - var cut = RenderComponent(ps => ps - .Add(p => p.EventName, eventName) - .Add(p => p.ChildElementType, elementType) - .Add(p => p.ChildElementDisabled, false)); + [Theory(DisplayName = "Enabled input elements does not bubble for event type"), PairwiseData] + public async Task Test114( + [CombinatorialValues("onclick", "ondblclick", "onmousedown", "onmousemove", "onmouseup")] string eventName, + [CombinatorialValues("button", "input", "textarea", "select")] string elementType) + { + var cut = RenderComponent(ps => ps + .Add(p => p.EventName, eventName) + .Add(p => p.ChildElementType, elementType) + .Add(p => p.ChildElementDisabled, false)); - await cut.Find("#child").TriggerEventAsync(eventName, EventArgs.Empty); + await cut.Find("#child").TriggerEventAsync(eventName, EventArgs.Empty); - cut.Instance.ChildTriggerCount.ShouldBe(1); - cut.Instance.ParentTriggerCount.ShouldBe(1); - cut.Instance.GrandParentTriggerCount.ShouldBe(1); - } + cut.Instance.ChildTriggerCount.ShouldBe(1); + cut.Instance.ParentTriggerCount.ShouldBe(1); + cut.Instance.GrandParentTriggerCount.ShouldBe(1); + } #if NET6_0_OR_GREATER - [Fact(DisplayName = "TriggerEvent can trigger custom events")] - public void Test201() - { - var cut = RenderComponent(); + [Fact(DisplayName = "TriggerEvent can trigger custom events")] + public void Test201() + { + var cut = RenderComponent(); - cut.Find("input").TriggerEvent("oncustompaste", new CustomPasteEventArgs - { - EventTimestamp = DateTime.Now, - PastedData = "FOO" - }); + cut.Find("input").TriggerEvent("oncustompaste", new CustomPasteEventArgs + { + EventTimestamp = DateTime.Now, + PastedData = "FOO" + }); - cut.Find("p:last-child").MarkupMatches("

You pasted: FOO

"); - } + cut.Find("p:last-child").MarkupMatches("

You pasted: FOO

"); + } #endif - [Fact(DisplayName = "TriggerEventAsync throws NoEventHandlerException when invoked with an unknown event handler ID")] - public void Test300() - { - var cut = RenderComponent(); - var buttons = cut.FindAll("button"); - buttons[0].Click(); + [Fact(DisplayName = "TriggerEventAsync throws NoEventHandlerException when invoked with an unknown event handler ID")] + public void Test300() + { + var cut = RenderComponent(); + var buttons = cut.FindAll("button"); + buttons[0].Click(); - Should.Throw(() => buttons[1].Click()); - } + Should.Throw(() => buttons[1].Click()); + } - [Fact(DisplayName = "Removed bubbled event handled NoEventHandlerException are ignored")] - public void Test301() - { - var cut = RenderComponent(); + [Fact(DisplayName = "Removed bubbled event handled NoEventHandlerException are ignored")] + public void Test301() + { + var cut = RenderComponent(); - cut.Find("button").Click(); + 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(); - } + // 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(ps => ps.Add(p => p.ExceptionMessage, exceptionMessage)); + [Theory(DisplayName = "When bubbling event throws, no other event handlers are triggered")] + [AutoData] + public void Test302(string exceptionMessage) + { + var cut = RenderComponent(ps => ps.Add(p => p.ExceptionMessage, exceptionMessage)); - Should.Throw(() => cut.Find("button").Click()) - .Message.ShouldBe(exceptionMessage); + Should.Throw(() => cut.Find("button").Click()) + .Message.ShouldBe(exceptionMessage); - cut.Instance.BtnClicked.ShouldBeTrue(); - cut.Instance.MiddleDivClicked.ShouldBeFalse(); - cut.Instance.TopDivClicked.ShouldBeFalse(); - } + 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(ps => ps.Add(p => p.ExceptionMessage, exceptionMessage)); + [Theory(DisplayName = "When event handler throws, the exception is passed up to test")] + [AutoData] + public void Test303(string exceptionMessage) + { + var cut = RenderComponent(ps => ps.Add(p => p.ExceptionMessage, exceptionMessage)); - Should.Throw(() => cut.Find("button").Click()) - .Message.ShouldBe(exceptionMessage); - } + Should.Throw(() => cut.Find("button").Click()) + .Message.ShouldBe(exceptionMessage); + } - [Fact(DisplayName = "Should handle click event first and submit form afterwards for button")] - public void Test304() - { - var cut = RenderComponent(); + [Fact(DisplayName = "Should handle click event first and submit form afterwards for button")] + public void Test304() + { + var cut = RenderComponent(); - cut.Find("button").Click(); + cut.Find("button").Click(); - cut.Instance.FormSubmitted.ShouldBeTrue(); - cut.Instance.Clicked.ShouldBeTrue(); - } + cut.Instance.FormSubmitted.ShouldBeTrue(); + cut.Instance.Clicked.ShouldBeTrue(); + } - [Fact(DisplayName = "Should handle click event first and submit form afterwards for input when type button")] - public void Test305() - { - var cut = RenderComponent(); + [Fact(DisplayName = "Should handle click event first and submit form afterwards for input when type button")] + public void Test305() + { + var cut = RenderComponent(); - cut.Find("#inside-form-input").Click(); + cut.Find("#inside-form-input").Click(); - cut.Instance.FormSubmitted.ShouldBeTrue(); - cut.Instance.Clicked.ShouldBeTrue(); - } + cut.Instance.FormSubmitted.ShouldBeTrue(); + cut.Instance.Clicked.ShouldBeTrue(); + } + + [Fact(DisplayName = "Should throw exception when invoking onsubmit from non form")] + public void Test306() + { + var cut = RenderComponent(); + + Should.Throw(() => cut.Find("button").Submit()); + } - [Fact(DisplayName = "Should throw exception when invoking onsubmit from non form")] - public void Test306() - { - var cut = RenderComponent(); Should.Throw(() => cut.Find("button").Submit()); } From 85e6bc0966dc16decc12c09cc6a19ea5425f7749 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Wed, 25 May 2022 23:05:34 +0000 Subject: [PATCH 05/21] feat: WaitForAssertion method marked as an assertion method --- CHANGELOG.md | 4 ++++ .../WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1abf7af..fbdd12ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### Changed + +- `WaitForAssertion` method is now marked as an assertion method with the `[AssertionMethod]` attribute. This makes certain analyzers like SonarSource's [Tests should include assertions](https://rules.sonarsource.com/csharp/RSPEC-2699) happy. By [@egil](https://github.com/egil). + ### Fixes - A race condition existed between `WaitForState` / `WaitForAssertion` and `FindComponents`, if the first used the latter. Reported by [@rmihael](https://github.com/rmihael), [@SviatoslavK](https://github.com/SviatoslavK), and [@RaphaelMarcouxCTRL](https://github.com/RaphaelMarcouxCTRL). Fixed by [@egil](https://github.com/egil) and [@linkdotnet](https://github.com/linkdotnet). diff --git a/src/bunit.core/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs b/src/bunit.core/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs index 1c6a4227e..2bf12d6e5 100644 --- a/src/bunit.core/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs +++ b/src/bunit.core/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs @@ -1,4 +1,5 @@ using System.Runtime.ExceptionServices; +using Bunit.Asserting; using Bunit.Extensions.WaitForHelpers; namespace Bunit; @@ -50,6 +51,7 @@ public static void WaitForState(this IRenderedFragmentBase renderedFragment, Fun /// The verification or assertion to perform. /// The maximum time to attempt the verification. /// Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception. + [AssertionMethod] public static void WaitForAssertion(this IRenderedFragmentBase renderedFragment, Action assertion, TimeSpan? timeout = null) { using var waiter = new WaitForAssertionHelper(renderedFragment, assertion, timeout); From dccdfa073af5446bebbd2f2968d5e4beb7fdd027 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Wed, 25 May 2022 23:20:30 +0000 Subject: [PATCH 06/21] fix: TestRenderer resets UnhandledException together with capturedUnhandledException --- src/bunit.core/Rendering/TestRenderer.cs | 47 ++++++++++++------- .../Rendering/TestRendererTest.cs | 2 - 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/bunit.core/Rendering/TestRenderer.cs b/src/bunit.core/Rendering/TestRenderer.cs index 817b3cf16..2997b8e15 100644 --- a/src/bunit.core/Rendering/TestRenderer.cs +++ b/src/bunit.core/Rendering/TestRenderer.cs @@ -76,6 +76,8 @@ public Task DispatchEventAsync( var result = Dispatcher.InvokeAsync(() => { + ResetUnhandledException(); + try { return base.DispatchEventAsync(eventHandlerId, fieldInfo, eventArgs); @@ -127,6 +129,8 @@ public void DisposeComponents() // will only work on IDisposable var disposeTask = Dispatcher.InvokeAsync(() => { + ResetUnhandledException(); + foreach (var root in rootComponents) { root.Detach(); @@ -142,20 +146,6 @@ public void DisposeComponents() AssertNoUnhandledExceptions(); } - /// - protected override void HandleException(Exception exception) - { - capturedUnhandledException = exception; - - logger.LogUnhandledException(capturedUnhandledException); - - if (!unhandledExceptionTsc.TrySetResult(capturedUnhandledException)) - { - unhandledExceptionTsc = new TaskCompletionSource(); - unhandledExceptionTsc.SetResult(capturedUnhandledException); - } - } - /// protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) { @@ -219,10 +209,10 @@ protected override void Dispose(bool disposing) private TResult Render(RenderFragment renderFragment, Func activator) where TResult : IRenderedFragmentBase { - ResetUnhandledException(); - var renderTask = Dispatcher.InvokeAsync(() => { + ResetUnhandledException(); + var root = new RootComponent(renderFragment); var rootComponentId = AssignRootComponentId(root); var result = activator(rootComponentId); @@ -350,7 +340,30 @@ private ArrayRange GetOrLoadRenderTreeFrame(RenderTreeFrameDict return framesCollection[componentId]; } - private void ResetUnhandledException() => capturedUnhandledException = null; + /// + protected override void HandleException(Exception exception) + { + if (exception is null) + return; + + logger.LogUnhandledException(exception); + + capturedUnhandledException = exception; + + if (!unhandledExceptionTsc.TrySetResult(capturedUnhandledException)) + { + unhandledExceptionTsc = new TaskCompletionSource(); + unhandledExceptionTsc.SetResult(capturedUnhandledException); + } + } + + private void ResetUnhandledException() + { + capturedUnhandledException = null; + + if (unhandledExceptionTsc.Task.IsCompleted) + unhandledExceptionTsc = new TaskCompletionSource(); + } private void AssertNoUnhandledExceptions() { diff --git a/tests/bunit.core.tests/Rendering/TestRendererTest.cs b/tests/bunit.core.tests/Rendering/TestRendererTest.cs index 6bf3b6515..fe153cff2 100644 --- a/tests/bunit.core.tests/Rendering/TestRendererTest.cs +++ b/tests/bunit.core.tests/Rendering/TestRendererTest.cs @@ -391,8 +391,6 @@ public async Task Test202() RenderComponent(ps => ps.Add(p => p.Awaitable, tsc2.Task)); tsc2.SetException(secondException); - await Task.Delay(50); - var secondExceptionReported = await Renderer.UnhandledException; secondExceptionReported.ShouldBe(secondException); firstExceptionReported.ShouldNotBe(secondException); From a21770d6ba01d884be446114b4cd18204e0910a6 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Wed, 25 May 2022 23:24:07 +0000 Subject: [PATCH 07/21] fix: TriggerEventAsync runs in renderer sync context to avoid race condition with DOM updates fixes #687 --- CHANGELOG.md | 2 + .../TriggerEventDispatchExtensions.cs | 41 ++++++++++++++++--- tests/.editorconfig | 2 + .../CounterComponentDynamic.razor | 23 +++++++++++ .../GeneralEventDispatchExtensionsTest.cs | 18 +++++++- 5 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 tests/bunit.testassets/SampleComponents/CounterComponentDynamic.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index fbdd12ea9..510835622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to **bUnit** will be documented in this file. The project ad - A race condition existed between `WaitForState` / `WaitForAssertion` and `FindComponents`, if the first used the latter. Reported by [@rmihael](https://github.com/rmihael), [@SviatoslavK](https://github.com/SviatoslavK), and [@RaphaelMarcouxCTRL](https://github.com/RaphaelMarcouxCTRL). Fixed by [@egil](https://github.com/egil) and [@linkdotnet](https://github.com/linkdotnet). +- Triggering of event handlers now runs entirely inside the renderers synchronization context, avoiding race condition between elements in the DOM tree being updated by the renderer and the event triggering logic traversing the DOM tree to find event handlers to trigger. Reported by [@FlukeFan](https://github.com/FlukeFan). Fixed by [@egil](https://github.com/egil). + ## [1.8.15] - 2022-05-19 ### Added diff --git a/src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs b/src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs index 9f4b41a5b..f73d6dc39 100644 --- a/src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs +++ b/src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Runtime.ExceptionServices; using AngleSharp.Dom; using AngleSharp.Html.Dom; using AngleSharpWrappers; @@ -56,7 +57,6 @@ public static void TriggerEvent(this IElement element, string eventName, EventAr /// The name of the event to raise (using on-form, e.g. onclick). /// The event arguments to pass to the event handler. /// A that completes when the render caused by the triggering of the event finishes. - [SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "HTML events are standardize to lower case and safe in this context.")] public static Task TriggerEventAsync(this IElement element, string eventName, EventArgs eventArgs) { if (element is null) @@ -67,18 +67,32 @@ public static Task TriggerEventAsync(this IElement element, string eventName, Ev var renderer = element.GetTestContext()?.Renderer ?? throw new InvalidOperationException($"Blazor events can only be raised on elements rendered with the Blazor test renderer '{nameof(ITestRenderer)}'."); - var isNonBubblingEvent = NonBubblingEvents.Contains(eventName.ToLowerInvariant()); + // TriggerEventsAsync will traverse the DOM tree to find + // all event handlers that needs to be triggered. This is done + // in the renderes synchronization context to avoid a race condition + // between the DOM tree being updated and traversed. + var result = renderer.Dispatcher.InvokeAsync( + () => TriggerEventsAsync(renderer, element, eventName, eventArgs)); + + ThrowIfFailedSynchronously(result); + + return result; + } + [SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "HTML events are standardize to lower case and safe in this context.")] + private static Task TriggerEventsAsync(ITestRenderer renderer, IElement element, string eventName, EventArgs eventArgs) + { + var isNonBubblingEvent = NonBubblingEvents.Contains(eventName.ToLowerInvariant()); var unwrappedElement = element.Unwrap(); if (isNonBubblingEvent) return TriggerNonBubblingEventAsync(renderer, unwrappedElement, eventName, eventArgs); return unwrappedElement switch { - IHtmlInputElement { Type: "submit", Form: not null } input when eventName is "onclick" => - TriggerFormSubmitBubblingEventAsync(renderer, input, eventArgs, input.Form), - IHtmlButtonElement { Type: "submit", Form: not null } button when eventName is "onclick" => - TriggerFormSubmitBubblingEventAsync(renderer, button, eventArgs, button.Form), + IHtmlInputElement { Type: "submit", Form: not null } input when eventName is "onclick" + => TriggerFormSubmitBubblingEventAsync(renderer, input, eventArgs, input.Form), + IHtmlButtonElement { Type: "submit", Form: not null } button when eventName is "onclick" + => TriggerFormSubmitBubblingEventAsync(renderer, button, eventArgs, button.Form), _ => TriggerBubblingEventAsync(renderer, unwrappedElement, eventName, eventArgs) }; } @@ -166,4 +180,19 @@ private static bool TryGetEventId(this IElement element, string blazorEventName, var eventId = element.GetAttribute(blazorEventName); return ulong.TryParse(eventId, NumberStyles.Integer, CultureInfo.InvariantCulture, out id); } + + private static void ThrowIfFailedSynchronously(Task result) + { + if (result.IsFaulted && result.Exception is not null) + { + if (result.Exception.InnerExceptions.Count == 1) + { + ExceptionDispatchInfo.Capture(result.Exception.InnerExceptions[0]).Throw(); + } + else + { + ExceptionDispatchInfo.Capture(result.Exception).Throw(); + } + } + } } diff --git a/tests/.editorconfig b/tests/.editorconfig index 51887c41e..d1c33837f 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -42,3 +42,5 @@ dotnet_diagnostic.CA2007.severity = none # https://github.com/atc-net dotnet_diagnostic.CA1819.severity = suggestion # CA1819: Properties should not return arrays dotnet_diagnostic.CA1849.severity = suggestion # CA1849: Call async methods when in an async method + +dotnet_diagnostic.xUnit1026.severity = none # xUnit1026: Theory methods should use all of their parameters diff --git a/tests/bunit.testassets/SampleComponents/CounterComponentDynamic.razor b/tests/bunit.testassets/SampleComponents/CounterComponentDynamic.razor new file mode 100644 index 000000000..48482f06e --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/CounterComponentDynamic.razor @@ -0,0 +1,23 @@ +

Counter Dynamic

+@foreach (var index in _values) +{ + +} + +@code { + private int[] _values = new int[0]; + + protected override async Task OnInitializedAsync() + { + await Task.Delay(1); + _values = new[] { 1 }; + await InvokeAsync(StateHasChanged); + } + + private void OnClick(int index) + { + _values = Enumerable.Range(1, index + 1).ToArray(); + } +} diff --git a/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs b/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs index 748cdfce5..27ce3f86c 100644 --- a/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs +++ b/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs @@ -266,8 +266,22 @@ public void Test306() Should.Throw(() => cut.Find("button").Submit()); } + public static IEnumerable GetTenNumbers() => Enumerable.Range(0, 10) + .Select(i => new object[] { i }); + + // Runs the test multiple times to trigger the race condition + // reliably. + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Needed to trigger multiple reruns of test.")] + [Theory(DisplayName = "TriggerEventAsync avoids race condition with DOM tree updates")] + [MemberData(nameof(GetTenNumbers))] + public void Test400(int i) + { + var cut = RenderComponent(); + + cut.WaitForAssertion(() => cut.Find("[data-id=1]")); + + cut.InvokeAsync(() => cut.Find("[data-id=1]").Click()); - Should.Throw(() => cut.Find("button").Submit()); - } + cut.WaitForAssertion(() => cut.Find("[data-id=2]")); } } From 68a4da2a3b37defeee3b531758bb8c18e831e9c9 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Wed, 25 May 2022 23:50:26 +0000 Subject: [PATCH 08/21] fix: improve exception message in UnknownEventHandlerIdException --- .../Rendering/UnknownEventHandlerIdException.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/bunit.core/Rendering/UnknownEventHandlerIdException.cs b/src/bunit.core/Rendering/UnknownEventHandlerIdException.cs index 45aef6053..f78575f43 100644 --- a/src/bunit.core/Rendering/UnknownEventHandlerIdException.cs +++ b/src/bunit.core/Rendering/UnknownEventHandlerIdException.cs @@ -20,7 +20,12 @@ private UnknownEventHandlerIdException(SerializationInfo serializationInfo, Stre 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."; + $"in the current render tree.{Environment.NewLine}{Environment.NewLine}" + + $"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()` call 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.{Environment.NewLine}{Environment.NewLine}" + + $"This can happen with code like this, `cut.Find(\"button\").Click()`, where the buttons event handler can be removed " + + $"between the time button is found with the Find method and the Click method is triggered. The workaround is to use " + + $"wrap the Find and Click method calls in InvokeAsync, i.e. `cut.InvokeAsync(() => cut.Find(\"button\").Click());`. " + + $"This ensures that there are no changes to the DOM between Find method and the Click method calls."; } From 98e97712a0e8d8f19317cd3b866f104a1b0ec306 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 26 May 2022 08:46:04 +0000 Subject: [PATCH 09/21] fix(run-tests.ps1): disable logging of successful tests when running all tests --- tests/run-tests.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/run-tests.ps1 b/tests/run-tests.ps1 index 3a149819f..51e7f05a4 100644 --- a/tests/run-tests.ps1 +++ b/tests/run-tests.ps1 @@ -16,8 +16,8 @@ for ($num = 1 ; $num -le $maxRuns ; $num++) } else { - dotnet test .\bunit.core.tests\bunit.core.tests.csproj -c $mode --no-restore --no-build --blame-hang --blame-hang-timeout 100s --nologo --logger:"console;verbosity=normal" - dotnet test .\bunit.web.tests\bunit.web.tests.csproj -c $mode --no-restore --no-build --blame-hang --blame-hang-timeout 100s --nologo --logger:"console;verbosity=normal" - dotnet test .\bunit.web.testcomponents.tests\bunit.web.testcomponents.tests.csproj -c $mode --no-restore --no-build --blame-hang --blame-hang-timeout 100s --nologo --logger:"console;verbosity=normal" + dotnet test .\bunit.core.tests\bunit.core.tests.csproj -c $mode --no-restore --no-build --blame-hang --blame-hang-timeout 100s --nologo + dotnet test .\bunit.web.tests\bunit.web.tests.csproj -c $mode --no-restore --no-build --blame-hang --blame-hang-timeout 100s --nologo + dotnet test .\bunit.web.testcomponents.tests\bunit.web.testcomponents.tests.csproj -c $mode --no-restore --no-build --blame-hang --blame-hang-timeout 100s --nologo } } From 45ba7d9df07363d3a4dab21ec5cff057d15cca0c Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 26 May 2022 08:57:07 +0000 Subject: [PATCH 10/21] fix(WaitForHelper): run init on renderers sync context --- .../WaitForHelpers/WaitForHelper.cs | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs b/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs index 6e3e8d579..66fb15794 100644 --- a/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs +++ b/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs @@ -89,22 +89,19 @@ private void InitializeWaiting() { if (!WaitTask.IsCompleted) { - var renderCountAtSubscribeTime = renderedFragment.RenderCount; - - // Before subscribing to renderedFragment.OnAfterRender, - // we need to make sure that the desired state has not already been reached. - OnAfterRender(this, EventArgs.Empty); - - SubscribeToOnAfterRender(); - - // If the render count from before subscribing has changes - // till now, we need to do trigger another check, since - // the render may have happened asynchronously and before - // the subscription was set up. - if (renderCountAtSubscribeTime < renderedFragment.RenderCount) + // Subscribe inside the renderers synchronization context + // to ensure no renders happens between the + // initial OnAfterRender and subscribing. + // This also ensures that checks performed during OnAfterRender, + // which are usually not atomic, e.g. search the DOM tree, + // can be performed without the DOM tree changing. + renderedFragment.InvokeAsync(() => { + // Before subscribing to renderedFragment.OnAfterRender, + // we need to make sure that the desired state has not already been reached. OnAfterRender(this, EventArgs.Empty); - } + SubscribeToOnAfterRender(); + }); } } From 897db29b20c1bea9221df487334d66c20e893df7 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 20 May 2022 19:23:16 +0000 Subject: [PATCH 11/21] build: enable osx, run template tests on windows also --- .github/workflows/verification.yml | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml index fca8f18ab..5fd3c662c 100644 --- a/.github/workflows/verification.yml +++ b/.github/workflows/verification.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] # macos-latest removed due to env error + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -63,45 +63,37 @@ jobs: - name: ๐Ÿงช Run unit tests run: | - dotnet test ./tests/bunit.core.tests/bunit.core.tests.csproj -c release --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type none - dotnet test ./tests/bunit.web.tests/bunit.web.tests.csproj -c release --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type none - dotnet test ./tests/bunit.web.testcomponents.tests/bunit.web.testcomponents.tests.csproj -c release --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type none + dotnet test ./tests/bunit.core.tests/bunit.core.tests.csproj -c release --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full + dotnet test ./tests/bunit.web.tests/bunit.web.tests.csproj -c release --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full + dotnet test ./tests/bunit.web.testcomponents.tests/bunit.web.testcomponents.tests.csproj -c release --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full - name: ๐Ÿ—ณ๏ธ Pack library run: | dotnet pack -c release -o ${GITHUB_WORKSPACE}/packages -p:ContinuousIntegrationBuild=true dotnet pack src/bunit/ -c release -o ${GITHUB_WORKSPACE}/packages -p:ContinuousIntegrationBuild=true dotnet pack src/bunit.template/ -c release -o ${GITHUB_WORKSPACE}/packages -p:ContinuousIntegrationBuild=true - - - name: โœณ Install bUnit template - if: matrix.os != 'windows-latest' - run: | - dotnet new --install bunit.template::${NBGV_NuGetPackageVersion} --nuget-source ${GITHUB_WORKSPACE}/packages # Excluding windows because the restore step doesnt seem to work correct. - name: โœ” Verify xUnit template - if: matrix.os != 'windows-latest' run: | dotnet new bunit --no-restore -o ${GITHUB_WORKSPACE}/TemplateTestXunit echo '' >> ${GITHUB_WORKSPACE}/TemplateTestXunit/Directory.Build.props dotnet restore ${GITHUB_WORKSPACE}/TemplateTestXunit --source ${GITHUB_WORKSPACE}/packages --source https://api.nuget.org/v3/index.json - dotnet test ${GITHUB_WORKSPACE}/TemplateTestXunit --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type none + dotnet test ${GITHUB_WORKSPACE}/TemplateTestXunit --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full - name: โœ” Verify NUnit template - if: matrix.os != 'windows-latest' run: | dotnet new bunit --framework nunit --no-restore -o ${GITHUB_WORKSPACE}/TemplateTestNunit echo '' >> ${GITHUB_WORKSPACE}/TemplateTestNunit/Directory.Build.props dotnet restore ${GITHUB_WORKSPACE}/TemplateTestNunit --source ${GITHUB_WORKSPACE}/packages --source https://api.nuget.org/v3/index.json - dotnet test ${GITHUB_WORKSPACE}/TemplateTestNunit --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type none + dotnet test ${GITHUB_WORKSPACE}/TemplateTestNunit --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full - name: โœ” Verify MSTest template - if: matrix.os != 'windows-latest' run: | dotnet new bunit --framework mstest --no-restore -o ${GITHUB_WORKSPACE}/TemplateTestMstest echo '' >> ${GITHUB_WORKSPACE}/TemplateTestMstest/Directory.Build.props dotnet restore ${GITHUB_WORKSPACE}/TemplateTestMstest --source ${GITHUB_WORKSPACE}/packages --source https://api.nuget.org/v3/index.json - dotnet test ${GITHUB_WORKSPACE}/TemplateTestMstest --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type none + dotnet test ${GITHUB_WORKSPACE}/TemplateTestMstest --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full # DocFx only works well on Windows currently - name: ๐Ÿ“„ Build documentation From 5e7e96667e38aae3d8bbfd2f4861436f9b68241b Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 26 May 2022 12:00:58 +0000 Subject: [PATCH 12/21] build: cancel verification wf run if another is started on same pr/branch --- .github/workflows/verification.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml index 5fd3c662c..a0431723e 100644 --- a/.github/workflows/verification.yml +++ b/.github/workflows/verification.yml @@ -15,6 +15,10 @@ on: - reopened workflow_dispatch: + +concurrency: + group: verification-${{ github.ref }}-1 + cancel-in-progress: true jobs: verify-bunit: From 454654c49785a36cb57a1ced1731fb32493e9386 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 26 May 2022 12:01:49 +0000 Subject: [PATCH 13/21] build: fix syntax for windows --- .github/workflows/verification.yml | 37 ++++++++++++++++-------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml index a0431723e..780c3c63b 100644 --- a/.github/workflows/verification.yml +++ b/.github/workflows/verification.yml @@ -73,31 +73,34 @@ jobs: - name: ๐Ÿ—ณ๏ธ Pack library run: | - dotnet pack -c release -o ${GITHUB_WORKSPACE}/packages -p:ContinuousIntegrationBuild=true - dotnet pack src/bunit/ -c release -o ${GITHUB_WORKSPACE}/packages -p:ContinuousIntegrationBuild=true - dotnet pack src/bunit.template/ -c release -o ${GITHUB_WORKSPACE}/packages -p:ContinuousIntegrationBuild=true - - # Excluding windows because the restore step doesnt seem to work correct. + dotnet pack -c release -o ${{ github.workspace }}/packages -p:ContinuousIntegrationBuild=true + dotnet pack src/bunit/ -c release -o ${{ github.workspace }}/packages -p:ContinuousIntegrationBuild=true + dotnet pack src/bunit.template/ -c release -o ${{ github.workspace }}/packages -p:ContinuousIntegrationBuild=true + + - name: โœณ Install bUnit template + run: | + dotnet new --install bunit.template::${NBGV_NuGetPackageVersion} --nuget-source ${{ github.workspace }}/packages + - name: โœ” Verify xUnit template run: | - dotnet new bunit --no-restore -o ${GITHUB_WORKSPACE}/TemplateTestXunit - echo '' >> ${GITHUB_WORKSPACE}/TemplateTestXunit/Directory.Build.props - dotnet restore ${GITHUB_WORKSPACE}/TemplateTestXunit --source ${GITHUB_WORKSPACE}/packages --source https://api.nuget.org/v3/index.json - dotnet test ${GITHUB_WORKSPACE}/TemplateTestXunit --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full + dotnet new bunit --no-restore -o ${{ github.workspace }}/TemplateTestXunit + echo '' >> ${{ github.workspace }}/TemplateTestXunit/Directory.Build.props + dotnet restore ${{ github.workspace }}/TemplateTestXunit --source https://api.nuget.org/v3/index.json --source ${{ github.workspace }}/packages + dotnet test ${{ github.workspace }}/TemplateTestXunit - name: โœ” Verify NUnit template run: | - dotnet new bunit --framework nunit --no-restore -o ${GITHUB_WORKSPACE}/TemplateTestNunit - echo '' >> ${GITHUB_WORKSPACE}/TemplateTestNunit/Directory.Build.props - dotnet restore ${GITHUB_WORKSPACE}/TemplateTestNunit --source ${GITHUB_WORKSPACE}/packages --source https://api.nuget.org/v3/index.json - dotnet test ${GITHUB_WORKSPACE}/TemplateTestNunit --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full + dotnet new bunit --framework nunit --no-restore -o ${{ github.workspace }}/TemplateTestNunit + echo '' >> ${{ github.workspace }}/TemplateTestNunit/Directory.Build.props + dotnet restore ${{ github.workspace }}/TemplateTestNunit --source https://api.nuget.org/v3/index.json --source ${{ github.workspace }}/packages + dotnet test ${{ github.workspace }}/TemplateTestNunit - name: โœ” Verify MSTest template run: | - dotnet new bunit --framework mstest --no-restore -o ${GITHUB_WORKSPACE}/TemplateTestMstest - echo '' >> ${GITHUB_WORKSPACE}/TemplateTestMstest/Directory.Build.props - dotnet restore ${GITHUB_WORKSPACE}/TemplateTestMstest --source ${GITHUB_WORKSPACE}/packages --source https://api.nuget.org/v3/index.json - dotnet test ${GITHUB_WORKSPACE}/TemplateTestMstest --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full + dotnet new bunit --framework mstest --no-restore -o ${{ github.workspace }}/TemplateTestMstest + echo '' >> ${{ github.workspace }}/TemplateTestMstest/Directory.Build.props + dotnet restore ${{ github.workspace }}/TemplateTestMstest --source https://api.nuget.org/v3/index.json --source ${{ github.workspace }}/packages + dotnet test ${{ github.workspace }}/TemplateTestMstest # DocFx only works well on Windows currently - name: ๐Ÿ“„ Build documentation From 0b8008cea729ba245c54370faf9e2d4c6ec62578 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 26 May 2022 12:02:07 +0000 Subject: [PATCH 14/21] build: collect blame and crash dumps, upload as artifacts --- .github/workflows/verification.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml index 780c3c63b..0e60cb02d 100644 --- a/.github/workflows/verification.yml +++ b/.github/workflows/verification.yml @@ -60,16 +60,21 @@ jobs: with: files: '["docs/site/*.md", "docs/**/*.md", "docs/**/*.tmpl.partial", "*.csproj", "**/*.csproj"]' - - name: ๐Ÿœ Ensure nuget.org source on Windows - if: matrix.os == 'windows-latest' - run: dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org --configfile $env:APPDATA\NuGet\NuGet.Config - continue-on-error: true - - name: ๐Ÿงช Run unit tests run: | - dotnet test ./tests/bunit.core.tests/bunit.core.tests.csproj -c release --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full - dotnet test ./tests/bunit.web.tests/bunit.web.tests.csproj -c release --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full - dotnet test ./tests/bunit.web.testcomponents.tests/bunit.web.testcomponents.tests.csproj -c release --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type full + dotnet test ./tests/bunit.core.tests/bunit.core.tests.csproj -c release --blame-hang-timeout 15s --blame-hang-dump-type full --blame-crash-dump-type full + dotnet test ./tests/bunit.web.tests/bunit.web.tests.csproj -c release --blame-hang-timeout 15s --blame-hang-dump-type full --blame-crash-dump-type full + dotnet test ./tests/bunit.web.testcomponents.tests/bunit.web.testcomponents.tests.csproj -c release --blame-hang-timeout 15s --blame-hang-dump-type full --blame-crash-dump-type full + + - name: ๐Ÿ“› Upload hang- and crash-dumps on test failure + if: failure() + uses: actions/upload-artifact@v3 + with: + if-no-files-found: ignore + name: test-dumps + path: | + **/*hangdump.dmp + **/*crashdump.dmp - name: ๐Ÿ—ณ๏ธ Pack library run: | From c1d651696eeb23a05735d25539a9be6683ce3a10 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 26 May 2022 12:13:57 +0000 Subject: [PATCH 15/21] build: verification nightly upload dump files --- .github/workflows/verification-dotnet-nightly.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verification-dotnet-nightly.yml b/.github/workflows/verification-dotnet-nightly.yml index 8e756ea13..86aeff4f2 100644 --- a/.github/workflows/verification-dotnet-nightly.yml +++ b/.github/workflows/verification-dotnet-nightly.yml @@ -33,8 +33,18 @@ jobs: - name: ๐Ÿงช Run unit tests run: | - dotnet test ./tests/bunit.core.tests/bunit.core.tests.csproj -c release --no-restore -f net7.0 --logger:"console;verbosity=normal" --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type none - dotnet test ./tests/bunit.web.tests/bunit.web.tests.csproj -c release --no-restore -f net7.0 --logger:"console;verbosity=normal" --blame-hang --blame-hang-timeout 1m --blame-hang-dump-type none + dotnet test ./tests/bunit.core.tests/bunit.core.tests.csproj -c release --no-restore -f net7.0 --logger:"console;verbosity=normal" --blame-hang-timeout 15s --blame-hang-dump-type full --blame-crash-dump-type full + dotnet test ./tests/bunit.web.tests/bunit.web.tests.csproj -c release --no-restore -f net7.0 --logger:"console;verbosity=normal" --blame-hang-timeout 15s --blame-hang-dump-type full --blame-crash-dump-type full + + - name: ๐Ÿ“› Upload hang- and crash-dumps on test failure + if: failure() + uses: actions/upload-artifact@v3 + with: + if-no-files-found: ignore + name: test-dumps + path: | + **/*hangdump.dmp + **/*crashdump.dmp - name: ๐Ÿงพ Collect dotnet information if: failure() From ab30b935df75a5d440f99ad13ae02b8a8c61ace2 Mon Sep 17 00:00:00 2001 From: TDroogers <34547552+TDroogers@users.noreply.github.com> Date: Tue, 31 May 2022 10:45:11 +0200 Subject: [PATCH 16/21] fix: Correct url in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 510835622..23b6903c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ All notable changes to **bUnit** will be documented in this file. The project ad - Fixed step by step guide for building and viewing the documentation locally. By [@linkdotnet](https://github.com/linkdotnet). -- `FakeNavigationManager.NavigateTo` could lead to exceptions when navigating to external url's. Reported by (@TDroogers)[https://github.com/TDroogers]. Fixed by [@linkdotnet](https://github.com/linkdotnet). +- `FakeNavigationManager.NavigateTo` could lead to exceptions when navigating to external url's. Reported by [@TDroogers](https://github.com/TDroogers). Fixed by [@linkdotnet](https://github.com/linkdotnet). ## [1.6.4] - 2022-02-22 From 01dcfa22a4b0964d329fc4cc8dc2fee6d0a49e00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 May 2022 15:09:24 +0000 Subject: [PATCH 17/21] build(deps): bump SonarAnalyzer.CSharp from 8.39.0.47922 to 8.40.0.48530 Bumps SonarAnalyzer.CSharp from 8.39.0.47922 to 8.40.0.48530. --- updated-dependencies: - dependency-name: SonarAnalyzer.CSharp dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index c3ab5513d..e001a52a5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -49,7 +49,7 @@ - + Date: Thu, 2 Jun 2022 18:37:06 +0200 Subject: [PATCH 18/21] build(deps): bump AngleSharp from 0.16.1 to 0.17.1 (#743) Bumps [AngleSharp](https://github.com/AngleSharp/AngleSharp) from 0.16.1 to 0.17.1. - [Release notes](https://github.com/AngleSharp/AngleSharp/releases) - [Commits](https://github.com/AngleSharp/AngleSharp/commits/v0.17.1) --- updated-dependencies: - dependency-name: AngleSharp dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/AngleSharpWrappers/AngleSharpWrappers.csproj | 2 +- src/bunit.web/bunit.web.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AngleSharpWrappers/AngleSharpWrappers.csproj b/src/AngleSharpWrappers/AngleSharpWrappers.csproj index fe8fb486a..12e55ec67 100644 --- a/src/AngleSharpWrappers/AngleSharpWrappers.csproj +++ b/src/AngleSharpWrappers/AngleSharpWrappers.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/bunit.web/bunit.web.csproj b/src/bunit.web/bunit.web.csproj index d2ec6b126..eb11df92e 100644 --- a/src/bunit.web/bunit.web.csproj +++ b/src/bunit.web/bunit.web.csproj @@ -15,7 +15,7 @@ - + From 91f2e538938d13f1ac5245e852d12c554bf434d9 Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Tue, 7 Jun 2022 10:56:35 +0000 Subject: [PATCH 19/21] Set version to '1.9' --- version.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.json b/version.json index faedce79b..b9cecf680 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.9-preview", + "version": "1.9", "assemblyVersion": { "precision": "revision" }, @@ -22,4 +22,4 @@ "pathFilters": [ "./src" ] -} +} \ No newline at end of file From 482d690042a39013b0571a56546b10cc181511ad Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Tue, 7 Jun 2022 11:07:39 +0000 Subject: [PATCH 20/21] Updated CHANGELOG.md for 1.9.8 release --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b6903c6..d3dac0d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,17 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +## [1.9.8] - 2022-06-07 + ### Changed -- `WaitForAssertion` method is now marked as an assertion method with the `[AssertionMethod]` attribute. This makes certain analyzers like SonarSource's [Tests should include assertions](https://rules.sonarsource.com/csharp/RSPEC-2699) happy. By [@egil](https://github.com/egil). +- `WaitForAssertion` method is now marked as an assertion method with the `[AssertionMethod]` attribute. This makes certain analyzers like SonarSource's [Tests should include assertions](https://rules.sonarsource.com/csharp/RSPEC-2699) happy. By [@egil](https://github.com/egil). ### Fixes -- A race condition existed between `WaitForState` / `WaitForAssertion` and `FindComponents`, if the first used the latter. Reported by [@rmihael](https://github.com/rmihael), [@SviatoslavK](https://github.com/SviatoslavK), and [@RaphaelMarcouxCTRL](https://github.com/RaphaelMarcouxCTRL). Fixed by [@egil](https://github.com/egil) and [@linkdotnet](https://github.com/linkdotnet). +- A race condition existed between `WaitForState` / `WaitForAssertion` and `FindComponents`, if the first used the latter. Reported by [@rmihael](https://github.com/rmihael), [@SviatoslavK](https://github.com/SviatoslavK), and [@RaphaelMarcouxCTRL](https://github.com/RaphaelMarcouxCTRL). Fixed by [@egil](https://github.com/egil) and [@linkdotnet](https://github.com/linkdotnet). -- Triggering of event handlers now runs entirely inside the renderers synchronization context, avoiding race condition between elements in the DOM tree being updated by the renderer and the event triggering logic traversing the DOM tree to find event handlers to trigger. Reported by [@FlukeFan](https://github.com/FlukeFan). Fixed by [@egil](https://github.com/egil). +- Triggering of event handlers now runs entirely inside the renderers synchronization context, avoiding race condition between elements in the DOM tree being updated by the renderer and the event triggering logic traversing the DOM tree to find event handlers to trigger. Reported by [@FlukeFan](https://github.com/FlukeFan). Fixed by [@egil](https://github.com/egil). ## [1.8.15] - 2022-05-19 @@ -1172,7 +1174,9 @@ The latest version of the library is availble on NuGet: - **Wrong casing on keyboard event dispatch helpers.** The helper methods for the keyboard events was not probably cased, so that has been updated. E.g. from `Keypress(...)` to `KeyPress(...)`. -[Unreleased]: https://github.com/bUnit-dev/bUnit/compare/v1.8.15...HEAD +[Unreleased]: https://github.com/bUnit-dev/bUnit/compare/v1.9.8...HEAD + +[1.9.8]: https://github.com/bUnit-dev/bUnit/compare/v1.8.15...v1.9.8 [1.8.15]: https://github.com/bUnit-dev/bUnit/compare/v1.7.7...v1.8.15 From 9a81bc9b93a81b31bb165bc6fb3e14beaf3a5024 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Mon, 27 Jun 2022 22:44:01 +0200 Subject: [PATCH 21/21] fix: Added missing opening tag (#775) --- docs/site/docs/providing-input/substituting-components.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/site/docs/providing-input/substituting-components.md b/docs/site/docs/providing-input/substituting-components.md index d2fa75511..2d38c191b 100644 --- a/docs/site/docs/providing-input/substituting-components.md +++ b/docs/site/docs/providing-input/substituting-components.md @@ -76,7 +76,7 @@ ctx.ComponentFactories.AddStub("
NOT FROM BAR
"); // Add the markup specified in the render fragment to the rendered // output instead of that from . -ctx.ComponentFactories.AddStub(@div>NOT FROM BAR); +ctx.ComponentFactories.AddStub(@
NOT FROM BAR
); ``` It is also possible to access the parameter that is passed to the substituted component, both when specifying alternative render output or when verifying the correct parameters was passed to the substituted component. For example, suppose `` has a parameter named `Baz`: @@ -88,7 +88,7 @@ ctx.ComponentFactories.AddStub(parameters => $"
{parameters.Get(x => Ba // Add the markup produced by the render template to the rendered // output instead of that from . -ctx.ComponentFactories.AddStub(parameters => @div>@(parameters.Get(x => Baz))
); +ctx.ComponentFactories.AddStub(parameters => @
@(parameters.Get(x => Baz))
); ``` To verify that the expected value was passed to the `Baz` parameter of ``, first find the substituted component in the render tree using the `FindComponent`/`FindComponents` methods, and then inspect the `Parameters` property. E.g.: @@ -136,7 +136,7 @@ ctx.ComponentFactories.AddStub(type => type.Namespace == "Third.Party.Lib", // Add the markup produced by the render fragment to the rendered // output instead of the components that match the predicate. ctx.ComponentFactories.AddStub(type => type.Namespace == "Third.Party.Lib", - @div>NOT FROM BAR); + @
NOT FROM BAR
); ``` ### Creating a mock component with mocking libraries