diff --git a/CHANGELOG.md b/CHANGELOG.md index a31a02826..40fd1b414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ List of new features. ### Changed List of changes in existing functionality. +- Related to [#189](https://github.com/egil/bUnit/issues/189), a bunch of the core `ITestRenderer` and related types have changed. The internals of `ITestRenderer` is now less exposed and the test renderer is now in control of when rendered components and rendered fragments are created, and when they are updated. This enables the test renderer to protect against race conditions when the `FindComponent`, `FindComponents`, `RenderFragment`, and `RenderComponent` methods are called. + ### Deprecated List of soon-to-be removed features. @@ -21,6 +23,8 @@ List of now removed features. ### Fixed List of any bug fixes. +- Fixes [#189](https://github.com/egil/bUnit/issues/189): The test renderer did not correctly protect against a race condition during initial rendering of a component, and that could in some rare circumstances cause a test to fail when it should not. This has been addressed in this release with a major rewrite of the test renderer, which now controls and owns the rendered component and rendered fragment instances which is created when a component is rendered. By [@egil](https://github.com/egil) in [#201](https://github.com/egil/bUnit/pull/201). Credits to [@Smurf-IV](https://github.com/Smurf-IV) for reporting and helping investigate this issue. + ### Security List of fixed security vulnerabilities. diff --git a/bunit.sln b/bunit.sln index a62447920..d405041fc 100644 --- a/bunit.sln +++ b/bunit.sln @@ -19,6 +19,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{6EA09ED4-B714-4E6F-B0E1-4D987F8AE520}" ProjectSection(SolutionItems) = preProject tests\Directory.Build.props = tests\Directory.Build.props + tests\run-tests.ps1 = tests\run-tests.ps1 EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".text", ".text", "{392FCD4E-356A-412A-A854-8EE197EA65B9}" diff --git a/src/bunit.core/Extensions/RenderedComponentInvokeAsyncExtension.cs b/src/bunit.core/Extensions/RenderedComponentInvokeAsyncExtension.cs index cf444aa99..0d1a69740 100644 --- a/src/bunit.core/Extensions/RenderedComponentInvokeAsyncExtension.cs +++ b/src/bunit.core/Extensions/RenderedComponentInvokeAsyncExtension.cs @@ -1,8 +1,11 @@ using System; using System.Text; using System.Threading.Tasks; + using Bunit.Rendering; + using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; namespace Bunit { @@ -23,7 +26,8 @@ public static Task InvokeAsync(this IRenderedComponentBase() + .Dispatcher.InvokeAsync(callback); } /// @@ -38,7 +42,8 @@ public static Task InvokeAsync(this IRenderedComponentBase() + .Dispatcher.InvokeAsync(callback); } } } diff --git a/src/bunit.core/Extensions/RenderedComponentRenderExtensions.cs b/src/bunit.core/Extensions/RenderedComponentRenderExtensions.cs index 4809acf80..d5a9432a3 100644 --- a/src/bunit.core/Extensions/RenderedComponentRenderExtensions.cs +++ b/src/bunit.core/Extensions/RenderedComponentRenderExtensions.cs @@ -53,7 +53,7 @@ public static void SetParametersAndRender(this IRenderedComponentBas /// The rendered component to re-render with new parameters /// An action that receives a . public static void SetParametersAndRender(this IRenderedComponentBase renderedComponent, Action> parameterBuilder) - where TComponent : IComponent + where TComponent : IComponent { if (renderedComponent is null) throw new ArgumentNullException(nameof(renderedComponent)); @@ -70,7 +70,7 @@ public static void SetParametersAndRender(this IRenderedComponentBas private static ParameterView ToParameterView(IReadOnlyList parameters) { var parameterView = ParameterView.Empty; - if (parameters.Any()) + if (parameters.Count > 0) { var paramDict = new Dictionary(); foreach (var param in parameters) diff --git a/src/bunit.core/IRenderedFragmentBase.cs b/src/bunit.core/IRenderedFragmentBase.cs index 6029f4946..3cfd4f733 100644 --- a/src/bunit.core/IRenderedFragmentBase.cs +++ b/src/bunit.core/IRenderedFragmentBase.cs @@ -1,28 +1,35 @@ using System; -using System.Threading.Tasks; + using Bunit.Rendering; +using Microsoft.AspNetCore.Components; namespace Bunit { /// - /// Represents a rendered fragment. + /// Represents a rendered . /// - public interface IRenderedFragmentBase + public interface IRenderedFragmentBase : IDisposable { /// - /// Gets the id of the rendered component or fragment. + /// Gets the total number times the fragment has been through its render life-cycle. /// - int ComponentId { get; } + int RenderCount { get; } /// - /// Gets the total number times the fragment has been through its render life-cycle. + /// Gets whether the rendered component or fragment has been disposed by the . /// - int RenderCount { get; } + bool IsDisposed { get; } /// - /// Adds or removes an event handler that will be triggered after each render of this . + /// Gets the id of the rendered component or fragment. /// - event Action OnAfterRender; + int ComponentId { get; } + + /// + /// Called by the owning when it finishes a render. + /// + /// A that represents a render. + void OnRender(RenderEvent renderEvent); /// /// Gets the used when rendering the component. @@ -30,8 +37,8 @@ public interface IRenderedFragmentBase IServiceProvider Services { get; } /// - /// Gets the renderer that rendered the component. + /// Adds or removes an event handler that will be triggered after each render of this . /// - ITestRenderer Renderer { get; } + event Action? OnAfterRender; } } diff --git a/src/bunit.core/Rendering/ComponentDisposedException.cs b/src/bunit.core/Rendering/ComponentDisposedException.cs index 56a8782b2..3aeeea3e5 100644 --- a/src/bunit.core/Rendering/ComponentDisposedException.cs +++ b/src/bunit.core/Rendering/ComponentDisposedException.cs @@ -5,7 +5,7 @@ namespace Bunit.Rendering { /// - /// Represents an exception that is thrown when a 's + /// Represents an exception that is thrown when a 's /// properties is accessed after the underlying component has been dispsoed by the renderer. /// public class ComponentDisposedException : Exception diff --git a/src/bunit.core/Rendering/IRenderEventHandler.cs b/src/bunit.core/Rendering/IRenderEventHandler.cs deleted file mode 100644 index 2ea606b8e..000000000 --- a/src/bunit.core/Rendering/IRenderEventHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Threading.Tasks; - -namespace Bunit.Rendering -{ - /// - /// Represents a type that handle - /// from a or one of the components it - /// has rendered. - /// - public interface IRenderEventHandler - { - /// - /// A handler for s. - /// Must return a completed task when it is done processing the render event. - /// - /// The render event to process - /// A that completes when the render event has been processed. - Task Handle(RenderEvent renderEvent); - } -} diff --git a/src/bunit.core/Rendering/IRenderEventProducer.cs b/src/bunit.core/Rendering/IRenderEventProducer.cs deleted file mode 100644 index d24d6ba43..000000000 --- a/src/bunit.core/Rendering/IRenderEventProducer.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Bunit.Rendering -{ - /// - /// Represents a producer of s. - /// - public interface IRenderEventProducer - { - /// - /// Adds a to this renderer, - /// which will be triggered when the renderer has finished rendering - /// a render cycle. - /// - /// The handler to add. - void AddRenderEventHandler(IRenderEventHandler handler); - - /// - /// Removes a from this renderer. - /// - /// The handler to remove. - void RemoveRenderEventHandler(IRenderEventHandler handler); - } -} diff --git a/src/bunit.core/Rendering/IRenderedComponentActivator.cs b/src/bunit.core/Rendering/IRenderedComponentActivator.cs new file mode 100644 index 000000000..e8c6b01b7 --- /dev/null +++ b/src/bunit.core/Rendering/IRenderedComponentActivator.cs @@ -0,0 +1,29 @@ + +using Microsoft.AspNetCore.Components; + +namespace Bunit.Rendering +{ + /// + /// Represents an activator for and types. + /// + public interface IRenderedComponentActivator + { + /// + /// Creates an with the specified . + /// + IRenderedFragmentBase CreateRenderedFragment(int componentId); + + /// + /// Creates an with the specified . + /// + IRenderedComponentBase CreateRenderedComponent(int componentId) + where TComponent : IComponent; + + /// + /// Creates an with the specified , + /// , and . + /// + IRenderedComponentBase CreateRenderedComponent(int componentId, TComponent component, RenderTreeFrameCollection componentFrames) + where TComponent : IComponent; + } +} diff --git a/src/bunit.core/Rendering/ITestRenderer.cs b/src/bunit.core/Rendering/ITestRenderer.cs index 9ea456f2d..1a8d75713 100644 --- a/src/bunit.core/Rendering/ITestRenderer.cs +++ b/src/bunit.core/Rendering/ITestRenderer.cs @@ -11,36 +11,13 @@ namespace Bunit.Rendering /// /// Represents a generalized Blazor renderer for testing purposes. /// - public interface ITestRenderer : IRenderEventProducer + public interface ITestRenderer { /// /// Gets the associated with this . /// Dispatcher Dispatcher { get; } - ///// - ///// Invokes the given in the context of this . - ///// - ///// - ///// A that will be completed when the action has finished executing. - //Task InvokeAsync(Action callback); - - /// - /// Instantiates and renders the component of type . - /// - /// Type of component to render. - /// Parameters to pass to the component during first render. - /// The component and its assigned id. - (int ComponentId, TComponent Component) RenderComponent(IEnumerable parameters) where TComponent : IComponent; - - /// - /// Renders the provided inside a wrapper and returns - /// the wrappers component id. - /// - /// to render. - /// The id of the wrapper component which the is rendered inside. - int RenderFragment(RenderFragment renderFragment); - /// /// Notifies the renderer that an event has occurred. /// @@ -51,26 +28,35 @@ public interface ITestRenderer : IRenderEventProducer Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo fieldInfo, EventArgs eventArgs); /// - /// Performs a depth-first search for a child component of the component with the . + /// Renders the . + /// + /// The to render. + /// A that provides access to the rendered . + IRenderedFragmentBase RenderFragment(RenderFragment renderFragment); + + /// + /// Renders a with the parameters passed to it. /// - /// Type of component to look for. - /// The id of the parent component. - /// The first matching child component. - (int ComponentId, TComponent Component) FindComponent(int parentComponentId); + /// The type of component to render. + /// The parameters to pass to the component. + /// A that provides access to the rendered component. + IRenderedComponentBase RenderComponent(IEnumerable componentParameters) + where TComponent : IComponent; /// - /// Performs a depth-first search for all child components of the component with the . + /// Performs a depth-first search for the first child component of the . /// - /// Type of components to look for. - /// The id of the parent component. - /// The matching child components. - IReadOnlyList<(int ComponentId, TComponent Component)> FindComponents(int parentComponentId); + /// Type of component to find. + /// Parent component to search. + IRenderedComponentBase FindComponent(IRenderedFragmentBase parentComponent) + where TComponent : IComponent; /// - /// Gets the current render tree for a given component. + /// Performs a depth-first search for all child components of the . /// - /// The id for the component. - /// The representing the current render tree. - ArrayRange GetCurrentRenderTreeFrames(int componentId); + /// Type of components to find. + /// Parent component to search. + IReadOnlyList> FindComponents(IRenderedFragmentBase parentComponent) + where TComponent : IComponent; } } diff --git a/src/bunit.core/Rendering/RenderEvent.cs b/src/bunit.core/Rendering/RenderEvent.cs index 22c3d3f67..c9c0f94c7 100644 --- a/src/bunit.core/Rendering/RenderEvent.cs +++ b/src/bunit.core/Rendering/RenderEvent.cs @@ -1,115 +1,121 @@ using System; +using System.Collections.Generic; +using System.Linq; + using Microsoft.AspNetCore.Components.RenderTree; namespace Bunit.Rendering { /// - /// Represents a render event from a . + /// Represents an render event from a . /// public sealed class RenderEvent { - private readonly ITestRenderer _renderer; private readonly RenderBatch _renderBatch; /// - /// Gets the related from the render. + /// A collection of , accessible via the ID + /// of the component they are created by. /// - public ref readonly RenderBatch RenderBatch => ref _renderBatch; + public RenderTreeFrameCollection Frames { get; } /// /// Creates an instance of the type. /// - public RenderEvent(in RenderBatch renderBatch, ITestRenderer renderer) + /// The update from the render event. + /// The from the current render. + internal RenderEvent(RenderBatch renderBatch, RenderTreeFrameCollection frames) { _renderBatch = renderBatch; - _renderer = renderer; + Frames = frames; } /// - /// Checks whether the a component with or one or more of - /// its sub components was changed during the . + /// Gets the render status for a . /// - /// Id of component to check for updates to. - /// True if contains updates to component, false otherwise. - public bool HasMarkupChanges(int componentId) + /// The to get the status for. + /// A tuple of statuses indicating whether the rendered component rendered during the render cycle, if it changed or if it was disposed. + public (bool rendered, bool changed, bool disposed) GetRenderStatus(IRenderedFragmentBase renderedComponent) { - return HasChangesToRoot(componentId); + if (renderedComponent is null) + throw new ArgumentNullException(nameof(renderedComponent)); - bool HasChangesToRoot(int componentId) - { - for (var i = 0; i < _renderBatch.UpdatedComponents.Count; i++) - { - ref var update = ref _renderBatch.UpdatedComponents.Array[i]; - if (update.ComponentId == componentId && update.Edits.Count > 0) - return true; - } + var result = (rendered: false, changed: false, disposed: false); - var renderFrames = _renderer.GetCurrentRenderTreeFrames(componentId); - return HasChangedToChildren(renderFrames); + if (DidComponentDispose(renderedComponent)) + { + result.disposed = true; } - - bool HasChangedToChildren(ArrayRange componentRenderTreeFrames) + else { - for (var i = 0; i < componentRenderTreeFrames.Count; i++) - { - ref var frame = ref componentRenderTreeFrames.Array[i]; - if (frame.FrameType == RenderTreeFrameType.Component) - if (HasChangesToRoot(frame.ComponentId)) - return true; - } - return false; + (result.rendered, result.changed) = GetRenderAndChangeStatus(renderedComponent); } + + return result; } - /// - /// Checks whether the a component with was disposed. - /// - /// Id of component to check. - /// True if component was disposed, false otherwise. - public bool DidComponentDispose(int componentId) + private bool DidComponentDispose(IRenderedFragmentBase renderedComponent) { for (var i = 0; i < _renderBatch.DisposedComponentIDs.Count; i++) - if (_renderBatch.DisposedComponentIDs.Array[i].Equals(componentId)) + if (_renderBatch.DisposedComponentIDs.Array[i].Equals(renderedComponent.ComponentId)) return true; return false; } /// - /// Checks whether the a component with or one or more of - /// its sub components was rendered during the . + /// This method determines if the or any of the + /// components underneath it in the render tree rendered and whether they they changed + /// their render tree during render. + /// + /// It does this by getting the status from the , + /// then from all its children, using a recursive pattern, where the internal methods + /// GetStatus and GetStatusFromChildren call each other until there are no more children, + /// or both a render and a change is found. /// - /// Id of component to check if rendered. - /// True if the component or a sub component rendered, false otherwise. - public bool DidComponentRender(int componentId) + private (bool rendered, bool hasChanges) GetRenderAndChangeStatus(IRenderedFragmentBase renderedComponent) { - return DidComponentRenderRoot(componentId); + var result = (rendered: false, hasChanges: false); + + GetStatus(renderedComponent.ComponentId); + + return result; - bool DidComponentRenderRoot(int componentId) + void GetStatus(int componentId) { for (var i = 0; i < _renderBatch.UpdatedComponents.Count; i++) { ref var update = ref _renderBatch.UpdatedComponents.Array[i]; if (update.ComponentId == componentId) - return true; + { + result.rendered = true; + result.hasChanges = update.Edits.Count > 0; + break; + } } - return DidChildComponentRender(componentId); + if (!result.hasChanges) + { + GetStatusFromChildren(componentId); + } } - bool DidChildComponentRender(int componentId) + void GetStatusFromChildren(int componentId) { - var frames = _renderer.GetCurrentRenderTreeFrames(componentId); - + var frames = Frames[componentId]; for (var i = 0; i < frames.Count; i++) { ref var frame = ref frames.Array[i]; if (frame.FrameType == RenderTreeFrameType.Component) - if (DidComponentRenderRoot(frame.ComponentId)) - return true; - } + { + GetStatus(frame.ComponentId); - return false; + if (result.hasChanges) + { + break; + } + } + } } } } diff --git a/src/bunit.core/Rendering/RenderTreeFrameCollection.cs b/src/bunit.core/Rendering/RenderTreeFrameCollection.cs new file mode 100644 index 000000000..a492460eb --- /dev/null +++ b/src/bunit.core/Rendering/RenderTreeFrameCollection.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Bunit.Rendering +{ + /// + /// Represents a collection of . + /// + public sealed class RenderTreeFrameCollection + { + private readonly Dictionary> _currentRenderTree = new Dictionary>(); + + /// + /// Gets the associated with the . + /// + /// + /// + public ArrayRange this[int componentId] => _currentRenderTree[componentId]; + + /// + /// Creates an instance of the , + /// + internal RenderTreeFrameCollection() { } + + /// + /// Checks whether the collection contains a for the . + /// + public bool Contains(int componentId) => _currentRenderTree.ContainsKey(componentId); + + internal void Add(int componentId, ArrayRange frames) => _currentRenderTree.Add(componentId, frames); + } +} diff --git a/src/bunit.core/Rendering/TestRenderer.cs b/src/bunit.core/Rendering/TestRenderer.cs index 474e927b2..02a7f5d43 100644 --- a/src/bunit.core/Rendering/TestRenderer.cs +++ b/src/bunit.core/Rendering/TestRenderer.cs @@ -1,86 +1,53 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Runtime.ExceptionServices; +using System.Text; using System.Threading.Tasks; -using Bunit.Extensions; - using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Bunit.Rendering { /// - /// Generalized Blazor renderer for testing purposes. + /// Represents a bUnit used to render Blazor components and fragments during bUnit tests. /// - public class TestRenderer : Renderer, ITestRenderer, IRenderEventProducer + public partial class TestRenderer : Renderer, ITestRenderer { + private readonly object _renderTreeAccessLock = new object(); private readonly ILogger _logger; - private readonly List _renderEventHandlers = new List(); + private readonly IRenderedComponentActivator _activator; private Exception? _unhandledException; + private readonly Dictionary _renderedComponents = new Dictionary(); /// public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault(); /// - /// Creates an instance of the class. + /// Creates an instance of the . /// - public TestRenderer(IServiceProvider services, ILoggerFactory loggerFactory) : base(services, loggerFactory) + public TestRenderer(IRenderedComponentActivator activator, IServiceProvider services, ILoggerFactory loggerFactory) : base(services, loggerFactory) { _logger = loggerFactory.CreateLogger(); - } - - /// - /// Adds a to this renderer, - /// which will be triggered when the renderer has finished rendering - /// a render cycle. - /// - /// The handler to add. - public void AddRenderEventHandler(IRenderEventHandler handler) => _renderEventHandlers.Add(handler); - - /// - /// Removes a from this renderer. - /// - /// The handler to remove. - public void RemoveRenderEventHandler(IRenderEventHandler handler) => _renderEventHandlers.Remove(handler); - - /// - public (int ComponentId, TComponent Component) RenderComponent(IEnumerable parameters) where TComponent : IComponent - { - var componentType = typeof(TComponent); - var renderFragment = parameters.ToComponentRenderFragment(); - var wrapperId = RenderFragmentInsideWrapper(renderFragment); - return FindComponent(wrapperId); + _activator = activator; } /// - public int RenderFragment(RenderFragment renderFragment) + public IRenderedFragmentBase RenderFragment(RenderFragment renderFragment) { - return RenderFragmentInsideWrapper(renderFragment); + return Render(renderFragment, id => _activator.CreateRenderedFragment(id)); } /// - public (int ComponentId, TComponent Component) FindComponent(int parentComponentId) + public IRenderedComponentBase RenderComponent(IEnumerable parameters) + where TComponent : IComponent { - var result = GetComponent(parentComponentId); - if (result.HasValue) - return result.Value; - else - throw new ComponentNotFoundException(typeof(TComponent)); - } - - /// - public IReadOnlyList<(int ComponentId, TComponent Component)> FindComponents(int parentComponentId) - { - return GetComponents(parentComponentId); - } - - /// - public new ArrayRange GetCurrentRenderTreeFrames(int componentId) - { - return base.GetCurrentRenderTreeFrames(componentId); + var fragment = parameters.ToComponentRenderFragment(); + return Render(fragment, id => _activator.CreateRenderedComponent(id)); } /// @@ -89,135 +56,220 @@ public int RenderFragment(RenderFragment renderFragment) if (fieldInfo is null) throw new ArgumentNullException(nameof(fieldInfo)); - _logger.LogDebug(new EventId(10, nameof(DispatchEventAsync)), $"Starting trigger of '{fieldInfo.FieldValue}'"); - var result = Dispatcher.InvokeAsync(() => base.DispatchEventAsync(eventHandlerId, fieldInfo, eventArgs)); AssertNoUnhandledExceptions(); - if (result.IsCompletedSuccessfully) + return result; + } + + /// + public IRenderedComponentBase FindComponent(IRenderedFragmentBase parentComponent) where TComponent : IComponent + { + var foundComponents = FindComponents(parentComponent, 1); + if (foundComponents.Count == 1) { - _logger.LogDebug(new EventId(11, nameof(DispatchEventAsync)), $"Finished trigger synchronously for '{fieldInfo.FieldValue}'"); + return foundComponents[0]; } else { - _logger.LogDebug(new EventId(13, nameof(DispatchEventAsync)), $"Event handler for '{fieldInfo.FieldValue}' returned an incomplete task with status {result.Status}"); - result = result.ContinueWith(x => - { - if (x.IsCompletedSuccessfully) - { - _logger.LogDebug(new EventId(12, nameof(DispatchEventAsync)), $"Finished trigger asynchronously for '{fieldInfo.FieldValue}'"); - } - }, TaskScheduler.Default); + throw new ComponentNotFoundException(typeof(TComponent)); } - - return result; } - private int RenderFragmentInsideWrapper(RenderFragment renderFragment) - { - var wrapper = new WrapperComponent(renderFragment); - - var wrapperId = AssignRootComponentId(wrapper); - AssertNoUnhandledExceptions(); - - Dispatcher.InvokeAsync(wrapper.Render).Wait(); - AssertNoUnhandledExceptions(); + /// + public IReadOnlyList> FindComponents(IRenderedFragmentBase parentComponent) where TComponent : IComponent + => FindComponents(parentComponent, int.MaxValue); - return wrapperId; + /// + 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) + { + base.ProcessPendingRender(); + } } /// - protected override void HandleException(Exception exception) => _unhandledException = exception; + protected override void HandleException(Exception exception) + => _unhandledException = exception; /// protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) { - _logger.LogDebug(new EventId(0, nameof(UpdateDisplayAsync)), $"New render batch with ReferenceFrames = {renderBatch.ReferenceFrames.Count}, UpdatedComponents = {renderBatch.UpdatedComponents.Count}, DisposedComponentIDs = {renderBatch.DisposedComponentIDs.Count}, DisposedEventHandlerIDs = {renderBatch.DisposedEventHandlerIDs.Count}"); - - return _renderEventHandlers.Count == 0 - ? Task.CompletedTask - : PublishRenderEvent(in renderBatch); - } + var renderEvent = new RenderEvent(renderBatch, new RenderTreeFrameCollection()); - private Task PublishRenderEvent(in RenderBatch renderBatch) - { - var renderEvent = new RenderEvent(in renderBatch, this); + // removes disposed components + for (var i = 0; i < renderBatch.DisposedComponentIDs.Count; i++) + { + var id = renderBatch.DisposedComponentIDs.Array[i]; + if (_renderedComponents.TryGetValue(id, out var rc)) + { + _renderedComponents.Remove(id); + rc.OnRender(renderEvent); + } + } - return _renderEventHandlers.Count switch + // notify each rendered component about the render + foreach (var (key, rc) in _renderedComponents.ToArray()) { - 0 => Task.CompletedTask, - 1 => _renderEventHandlers[0].Handle(renderEvent), - _ => NotifyEventHandlers(renderEvent) - }; - } + LoadRenderTreeFrames(rc.ComponentId, renderEvent.Frames); - private Task NotifyEventHandlers(RenderEvent renderEvent) - { - // copy to new array since _renderEventHandlers might be modified by the - // Handle method on event handlers if component is disposed. - var handleTasks = _renderEventHandlers - .ToArray() - .Select(x => x.Handle(renderEvent)); + rc.OnRender(renderEvent); + + // RC can replace the instance of the component is bound + // to while processing the update event. + if (key != rc.ComponentId) + { + _renderedComponents.Remove(key); + _renderedComponents.Add(rc.ComponentId, rc); + } + } - return Task.WhenAll(handleTasks); + return Task.CompletedTask; } - private void AssertNoUnhandledExceptions() + /// + protected override void Dispose(bool disposing) { - if (_unhandledException is { } unhandled) + if (disposing) { - _unhandledException = null; - var evt = new EventId(3, nameof(AssertNoUnhandledExceptions)); - _logger.LogError(evt, unhandled, $"An unhandled exception happened during rendering: {unhandled.Message}{Environment.NewLine}{unhandled.StackTrace}"); - ExceptionDispatchInfo.Capture(unhandled).Throw(); + foreach (var rc in _renderedComponents.Values) + { + rc.Dispose(); + } + _renderedComponents.Clear(); } + base.Dispose(disposing); } - private (int ComponentId, TComponent Component)? GetComponent(int rootComponentId) + private TResult Render(RenderFragment renderFragment, Func activator) + where TResult : IRenderedFragmentBase { - var ownFrames = GetCurrentRenderTreeFrames(rootComponentId); + TResult renderedComponent = default!; - for (var i = 0; i < ownFrames.Count; i++) + var task = Dispatcher.InvokeAsync(() => { - ref var frame = ref ownFrames.Array[i]; - if (frame.FrameType == RenderTreeFrameType.Component) - { - if (frame.Component is TComponent component) - return (frame.ComponentId, component); + var root = new WrapperComponent(renderFragment); + var rootComponentId = AssignRootComponentId(root); + renderedComponent = activator(rootComponentId); + _renderedComponents.Add(rootComponentId, renderedComponent); + root.Render(); + }); - var result = GetComponent(frame.ComponentId); - if (result is { }) - return result; - } - } + task.Wait(); - return null; + AssertNoUnhandledExceptions(); + + return renderedComponent!; } - private IReadOnlyList<(int ComponentId, TComponent Component)> GetComponents(int rootComponentId) + private IReadOnlyList> FindComponents(IRenderedFragmentBase parentComponent, int resultLimit) + where TComponent : IComponent { - var result = new List<(int ComponentId, TComponent Component)>(); - - GetComponentsInternal(rootComponentId, result); + if (parentComponent is null) + throw new ArgumentNullException(nameof(parentComponent)); + + var result = new List>(); + var framesCollection = new RenderTreeFrameCollection(); + + // 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) + { + FindComponentsInternal(parentComponent.ComponentId); + foreach (var rc in result) + { + _renderedComponents.Add(rc.ComponentId, rc); + } + } return result; - void GetComponentsInternal(int rootComponentId, List<(int ComponentId, TComponent Component)> result) + void FindComponentsInternal(int componentId) { - var ownFrames = GetCurrentRenderTreeFrames(rootComponentId); - for (var i = 0; i < ownFrames.Count; i++) + var frames = GetOrLoadRenderTreeFrame(framesCollection, componentId); + + for (var i = 0; i < frames.Count; i++) { - ref var frame = ref ownFrames.Array[i]; + ref var frame = ref frames.Array[i]; if (frame.FrameType == RenderTreeFrameType.Component) { if (frame.Component is TComponent component) - result.Add((frame.ComponentId, component)); + { + var id = frame.ComponentId; + LoadRenderTreeFrames(id, framesCollection); + var rc = _activator.CreateRenderedComponent(id, component, framesCollection); + result.Add(rc); - GetComponentsInternal(frame.ComponentId, result); + if (result.Count == resultLimit) + return; + } + + FindComponentsInternal(frame.ComponentId); + + if (result.Count == resultLimit) + return; } } } } + + /// + /// Populates the with + /// starting with the one that belongs to the component with ID . + /// + private void LoadRenderTreeFrames(int componentId, RenderTreeFrameCollection framesCollection) + { + var frames = GetOrLoadRenderTreeFrame(framesCollection, componentId); + + for (var i = 0; i < frames.Count; i++) + { + ref var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Component) + { + LoadRenderTreeFrames(frame.ComponentId, framesCollection); + } + } + } + + /// + /// Gets the from the . + /// If the does not contain the frames, they are loaded into it first. + /// + private ArrayRange GetOrLoadRenderTreeFrame(RenderTreeFrameCollection framesCollection, int componentId) + { + if (!framesCollection.Contains(componentId)) + { + var frames = GetCurrentRenderTreeFrames(componentId); + framesCollection.Add(componentId, frames); + } + + return framesCollection[componentId]; + } + + private void AssertNoUnhandledExceptions() + { + if (_unhandledException is { } unhandled) + { + _unhandledException = null; + LogUnhandledException(unhandled); + ExceptionDispatchInfo.Capture(unhandled).Throw(); + } + } + + private void LogUnhandledException(Exception unhandled) + { + var evt = new EventId(3, nameof(AssertNoUnhandledExceptions)); + _logger.LogError(evt, unhandled, $"An unhandled exception happened during rendering: {unhandled.Message}{Environment.NewLine}{unhandled.StackTrace}"); + } } } diff --git a/src/bunit.core/TestContextBase.cs b/src/bunit.core/TestContextBase.cs index 3ccfff964..da03312ad 100644 --- a/src/bunit.core/TestContextBase.cs +++ b/src/bunit.core/TestContextBase.cs @@ -1,4 +1,5 @@ using System; +using System.Xml.Serialization; using Bunit.Rendering; @@ -37,7 +38,7 @@ public ITestRenderer Renderer public TestContextBase() { Services = new TestServiceProvider(); - Services.AddSingleton(srv => new TestRenderer(srv, srv.GetService() ?? NullLoggerFactory.Instance)); + Services.AddSingleton(); } /// diff --git a/src/bunit.web/Diffing/BlazorDiffingHelpers.cs b/src/bunit.web/Diffing/BlazorDiffingHelpers.cs index 429fb6ea7..93c2a158a 100644 --- a/src/bunit.web/Diffing/BlazorDiffingHelpers.cs +++ b/src/bunit.web/Diffing/BlazorDiffingHelpers.cs @@ -10,7 +10,7 @@ namespace Bunit.Diffing public static class BlazorDiffingHelpers { /// - /// Represents a diffing filter that removes all special Blazor attributes added by the /. + /// Represents a diffing filter that removes all special Blazor attributes added by the /. /// public static FilterDecision BlazorEventHandlerIdAttrFilter(in AttributeComparisonSource attrSource, FilterDecision currentDecision) { diff --git a/src/bunit.web/Diffing/DiffMarkupFormatter.cs b/src/bunit.web/Diffing/DiffMarkupFormatter.cs index 7ef881b04..f8cdb8c10 100644 --- a/src/bunit.web/Diffing/DiffMarkupFormatter.cs +++ b/src/bunit.web/Diffing/DiffMarkupFormatter.cs @@ -9,7 +9,7 @@ namespace Bunit.Diffing { /// - /// A markup formatter, that skips any special Blazor attributes added by the /. + /// A markup formatter, that skips any special Blazor attributes added by the . /// public class DiffMarkupFormatter : PrettyMarkupFormatter, IMarkupFormatter { diff --git a/src/bunit.web/Diffing/HtmlComparer.cs b/src/bunit.web/Diffing/HtmlComparer.cs index 3240d1a21..5e7d385e3 100644 --- a/src/bunit.web/Diffing/HtmlComparer.cs +++ b/src/bunit.web/Diffing/HtmlComparer.cs @@ -12,7 +12,7 @@ namespace Bunit.Diffing { /// - /// Represents a test HTML comparer, that is configured to work with markup generated by the and classes. + /// Represents a test HTML comparer, that is configured to work with markup generated by the and classes. /// public sealed class HtmlComparer { diff --git a/src/bunit.web/Diffing/HtmlParser.cs b/src/bunit.web/Diffing/HtmlParser.cs index fd6112f8b..378f2019c 100644 --- a/src/bunit.web/Diffing/HtmlParser.cs +++ b/src/bunit.web/Diffing/HtmlParser.cs @@ -42,7 +42,7 @@ public HtmlParser(ITestRenderer testRenderer, HtmlComparer htmlComparer) { var config = Configuration.Default .WithCss() - .With(testRenderer) + .With(testRenderer) // added to allow elements to find the renderer to trigger events .With(htmlComparer) .With(this); diff --git a/src/bunit.web/EventDispatchExtensions/GeneralEventDispatchExtensions.cs b/src/bunit.web/EventDispatchExtensions/GeneralEventDispatchExtensions.cs index 70a6ca82d..360ff6467 100644 --- a/src/bunit.web/EventDispatchExtensions/GeneralEventDispatchExtensions.cs +++ b/src/bunit.web/EventDispatchExtensions/GeneralEventDispatchExtensions.cs @@ -24,7 +24,6 @@ public static class GeneralEventDispatchExtensions /// The name of the event to raise (using on-form, e.g. onclick). /// The event arguments to pass to the event handler /// - [SuppressMessage("Usage", "BL0006:Do not use RenderTree types", Justification = "")] public static Task TriggerEventAsync(this IElement element, string eventName, EventArgs eventArgs) { if (element is null) diff --git a/src/bunit.web/Extensions/RenderedFragmentExtensions.cs b/src/bunit.web/Extensions/RenderedFragmentExtensions.cs index 280979d91..923f167ec 100644 --- a/src/bunit.web/Extensions/RenderedFragmentExtensions.cs +++ b/src/bunit.web/Extensions/RenderedFragmentExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using AngleSharp.Dom; @@ -63,8 +64,7 @@ public static IRenderedComponent FindComponent(this IRen throw new ArgumentNullException(nameof(renderedFragment)); var renderer = renderedFragment.Services.GetRequiredService(); - var (id, component) = renderer.FindComponent(renderedFragment.ComponentId); - return new RenderedComponent(renderedFragment.Services, id, component); + return (IRenderedComponent)renderer.FindComponent(renderedFragment); } /// @@ -79,15 +79,9 @@ public static IReadOnlyList> FindComponents(); - var components = renderer.FindComponents(renderedFragment.ComponentId); - var result = components.Count == 0 ? Array.Empty>() : new IRenderedComponent[components.Count]; + var components = renderer.FindComponents(renderedFragment); - for (int i = 0; i < components.Count; i++) - { - result[i] = new RenderedComponent(renderedFragment.Services, components[i].ComponentId, components[i].Component); - } - - return result; + return components.Cast>().ToArray(); } } } diff --git a/src/bunit.web/Extensions/TestRendererExtensions.cs b/src/bunit.web/Extensions/TestRendererExtensions.cs new file mode 100644 index 000000000..60f7e8174 --- /dev/null +++ b/src/bunit.web/Extensions/TestRendererExtensions.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Bunit.Rendering; +using Microsoft.AspNetCore.Components; + +namespace Bunit.Extensions +{ + /// + /// Helper methods that make it easier to work directly with a + /// in bUnit web. + /// + public static class TestRendererExtensions + { + /// + /// Renders a with the parameters passed to it. + /// + /// The type of component to render. + /// The renderer to use. + /// The parameters to pass to the component. + /// A that provides access to the rendered component. + public static IRenderedComponent RenderComponent(this ITestRenderer renderer, params ComponentParameter[] parameters) + where TComponent : IComponent + { + if (renderer is null) throw new ArgumentNullException(nameof(renderer)); + + var resultBase = renderer.RenderComponent(parameters); + if (resultBase is IRenderedComponent result) + return result; + else + throw new InvalidOperationException($"The renderer did not produce the expected type. Is the test renderer using the expected {nameof(IRenderedComponentActivator)}?"); + } + + /// + /// Renders a with the parameters build with the passed to it. + /// + /// The type of component to render. + /// The renderer to use. + /// The a builder to create parameters to pass to the component. + /// A that provides access to the rendered component. + public static IRenderedComponent RenderComponent(this ITestRenderer renderer, Action> parameterBuilder) + where TComponent : IComponent + { + if (renderer is null) throw new ArgumentNullException(nameof(renderer)); + if (parameterBuilder is null) throw new ArgumentNullException(nameof(parameterBuilder)); + + var builder = new ComponentParameterBuilder(); + parameterBuilder(builder); + + var resultBase = renderer.RenderComponent(builder.Build()); + if (resultBase is IRenderedComponent result) + return result; + else + throw new InvalidOperationException($"The renderer did not produce the expected type. Is the test renderer using the expected {nameof(IRenderedComponentActivator)}?"); + } + } +} diff --git a/src/bunit.web/Extensions/TestServiceProviderExtensions.cs b/src/bunit.web/Extensions/TestServiceProviderExtensions.cs index 3000071ec..8b916c579 100644 --- a/src/bunit.web/Extensions/TestServiceProviderExtensions.cs +++ b/src/bunit.web/Extensions/TestServiceProviderExtensions.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.JSInterop; namespace Bunit.Extensions @@ -19,15 +21,13 @@ public static class TestServiceProviderExtensions /// public static IServiceCollection AddDefaultTestContextServices(this IServiceCollection services) { - services.AddSingleton(new PlaceholderAuthenticationStateProvider()); - services.AddSingleton(new PlaceholderAuthorizationService()); - services.AddSingleton(new PlaceholderJSRuntime()); - services.AddSingleton(srv => new HtmlComparer()); - services.AddSingleton(srv => new HtmlParser( - srv.GetRequiredService(), - srv.GetRequiredService() - ) - ); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/bunit.web/IRenderedFragment.cs b/src/bunit.web/IRenderedFragment.cs index f0c43766a..dc3ab85aa 100644 --- a/src/bunit.web/IRenderedFragment.cs +++ b/src/bunit.web/IRenderedFragment.cs @@ -4,6 +4,8 @@ using AngleSharp.Diffing.Core; using AngleSharp.Dom; +using Bunit.Rendering; + namespace Bunit { /// @@ -12,14 +14,14 @@ namespace Bunit public interface IRenderedFragment : IRenderedFragmentBase { /// - /// Gets the HTML markup from the rendered fragment/component. + /// An event that is raised after the markup of the is updated. /// - string Markup { get; } + event Action OnMarkupUpdated; /// - /// An event that is raised after the markup of the is updated. + /// Gets the HTML markup from the rendered fragment/component. /// - event Action OnMarkupUpdated; + string Markup { get; } /// /// Gets the AngleSharp based @@ -49,5 +51,7 @@ public interface IRenderedFragment : IRenderedFragmentBase /// the snapshot and the rendered markup at that time. /// void SaveSnapshot(); + + } } diff --git a/src/bunit.web/RazorTesting/Fixture.cs b/src/bunit.web/RazorTesting/Fixture.cs index 5597f666c..977d2f732 100644 --- a/src/bunit.web/RazorTesting/Fixture.cs +++ b/src/bunit.web/RazorTesting/Fixture.cs @@ -6,6 +6,7 @@ using Bunit.Extensions; using Bunit.RazorTesting; using Bunit.Rendering; + using Microsoft.AspNetCore.Components; namespace Bunit @@ -22,8 +23,9 @@ private IReadOnlyList TestData { if (_testData is null) { - var id = Renderer.RenderFragment(ChildContent!); - _testData = Renderer.FindComponents(id).Select(x => x.Component).ToArray(); + var renderedFragment = Renderer.RenderFragment(ChildContent!); + var comps = Renderer.FindComponents(renderedFragment); + _testData = comps.Select(x => x.Instance).ToArray(); } return _testData; } @@ -124,15 +126,13 @@ private ComponentUnderTest SelectComponentUnderTest(string _) private IRenderedComponent Factory(RenderFragment fragment) where TComponent : IComponent { - var renderId = Renderer.RenderFragment(fragment); - var (componentId, component) = Renderer.FindComponent(renderId); - return new RenderedComponent(Services, componentId, component); + var renderedFragment = Renderer.RenderFragment(fragment); + return (IRenderedComponent)Renderer.FindComponent(renderedFragment); } private IRenderedFragment Factory(RenderFragment fragment) { - var renderId = Renderer.RenderFragment(fragment); - return new RenderedFragment(Services, renderId); + return (IRenderedFragment)Renderer.RenderFragment(fragment); } private IRenderedComponent TryCastTo(IRenderedFragment target, [System.Runtime.CompilerServices.CallerMemberName] string sourceMethod = "") where TComponent : IComponent diff --git a/src/bunit.web/RazorTesting/SnapshotTest.cs b/src/bunit.web/RazorTesting/SnapshotTest.cs index 8c78f304e..6a7f39541 100644 --- a/src/bunit.web/RazorTesting/SnapshotTest.cs +++ b/src/bunit.web/RazorTesting/SnapshotTest.cs @@ -53,11 +53,11 @@ protected override async Task Run() if (SetupAsync is { }) await TryRunAsync(SetupAsync, this).ConfigureAwait(false); - var testRenderId = Renderer.RenderFragment(TestInput!); - var inputHtml = Htmlizer.GetHtml(Renderer, testRenderId); + var renderedTestInput = (IRenderedFragment)Renderer.RenderFragment(TestInput!); + var inputHtml = renderedTestInput.Markup; - var expectedRenderId = Renderer.RenderFragment(ExpectedOutput!); - var expectedHtml = Htmlizer.GetHtml(Renderer, expectedRenderId); + var renderedExpectedRender = (IRenderedFragment)Renderer.RenderFragment(ExpectedOutput!); + var expectedHtml = renderedExpectedRender.Markup; VerifySnapshot(inputHtml, expectedHtml); } diff --git a/src/bunit.web/Rendering/Internal/Htmlizer.cs b/src/bunit.web/Rendering/Internal/Htmlizer.cs index 6dcab3555..6f6fb0917 100644 --- a/src/bunit.web/Rendering/Internal/Htmlizer.cs +++ b/src/bunit.web/Rendering/Internal/Htmlizer.cs @@ -15,8 +15,7 @@ namespace Bunit /// This file is based on /// https://source.dot.net/#Microsoft.AspNetCore.Mvc.ViewFeatures/RazorComponents/HtmlRenderer.cs /// - [SuppressMessage("Usage", "BL0006:Do not use RenderTree types", Justification = "")] - internal class Htmlizer + internal static class Htmlizer { private static readonly HtmlEncoder HtmlEncoder = HtmlEncoder.Default; @@ -27,22 +26,25 @@ internal class Htmlizer private const string BLAZOR_INTERNAL_ATTR_PREFIX = "__internal_"; private const string BLAZOR_CSS_SCOPE_ATTR_PREFIX = "b-"; - public const string BLAZOR_ATTR_PREFIX = "blazor:"; - public const string ELEMENT_REFERENCE_ATTR_NAME = BLAZOR_ATTR_PREFIX + "elementreference"; + internal const string BLAZOR_ATTR_PREFIX = "blazor:"; + internal const string ELEMENT_REFERENCE_ATTR_NAME = BLAZOR_ATTR_PREFIX + "elementreference"; public static bool IsBlazorAttribute(string attributeName) - => attributeName.StartsWith(BLAZOR_ATTR_PREFIX, StringComparison.Ordinal) || - attributeName.StartsWith(BLAZOR_CSS_SCOPE_ATTR_PREFIX, StringComparison.Ordinal); + { + if (attributeName is null) throw new ArgumentNullException(nameof(attributeName)); + return attributeName.StartsWith(BLAZOR_ATTR_PREFIX, StringComparison.Ordinal) || + attributeName.StartsWith(BLAZOR_CSS_SCOPE_ATTR_PREFIX, StringComparison.Ordinal); + } public static string ToBlazorAttribute(string attributeName) { return $"{BLAZOR_ATTR_PREFIX}{attributeName}"; } - public static string GetHtml(ITestRenderer renderer, int componentId) + public static string GetHtml(int componentId, RenderTreeFrameCollection framesCollection) { - var frames = renderer.GetCurrentRenderTreeFrames(componentId); - var context = new HtmlRenderingContext(renderer); + var context = new HtmlRenderingContext(framesCollection); + var frames = context.GetRenderTreeFrames(componentId); var newPosition = RenderFrames(context, frames, 0, frames.Count); Debug.Assert(newPosition == frames.Count, $"frames.Count = {frames.Count}. newPosition = {newPosition}"); return string.Join(string.Empty, context.Result); @@ -101,7 +103,7 @@ private static int RenderChildComponent( int position) { ref var frame = ref frames.Array[position]; - var childFrames = context.Renderer.GetCurrentRenderTreeFrames(frame.ComponentId); + var childFrames = context.GetRenderTreeFrames(frame.ComponentId); RenderFrames(context, childFrames, 0, childFrames.Count); return position + frame.ComponentSubtreeLength; } @@ -264,13 +266,16 @@ private static int RenderAttributes( private class HtmlRenderingContext { - public ITestRenderer Renderer { get; } + private readonly RenderTreeFrameCollection _frames; - public HtmlRenderingContext(ITestRenderer renderer) + public HtmlRenderingContext(RenderTreeFrameCollection frames) { - Renderer = renderer; + _frames = frames; } + public ArrayRange GetRenderTreeFrames(int componentId) + => _frames[componentId]; + public List Result { get; } = new List(); public string? ClosestSelectValueAsString { get; set; } diff --git a/src/bunit.web/Rendering/RenderedComponent.cs b/src/bunit.web/Rendering/RenderedComponent.cs index 83a22f80b..a61ae64fe 100644 --- a/src/bunit.web/Rendering/RenderedComponent.cs +++ b/src/bunit.web/Rendering/RenderedComponent.cs @@ -2,14 +2,16 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; + using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.RenderTree; namespace Bunit.Rendering { /// internal class RenderedComponent : RenderedFragment, IRenderedComponent where TComponent : IComponent { - private readonly TComponent _instance; + private TComponent _instance = default!; /// public TComponent Instance @@ -17,13 +19,72 @@ public TComponent Instance get { EnsureComponentNotDisposed(); - return _instance; + return _instance ?? throw new InvalidOperationException("Component has not rendered yet..."); + } + } + + internal RenderedComponent(int componentId, IServiceProvider services) : base(componentId, services) { } + + internal RenderedComponent(int componentId, TComponent instance, RenderTreeFrameCollection componentFrames, IServiceProvider services) : base(componentId, services) + { + _instance = instance; + RenderCount++; + UpdateMarkup(componentFrames); + } + + protected override void OnRender(RenderEvent renderEvent) + { + // checks if this is the first render, and if it is + // tries to find the TCompoent in the render event + if (_instance is null) + { + SetComponentAndID(renderEvent); } } - public RenderedComponent(IServiceProvider services, int componentId, TComponent component) : base(services, componentId) + private void SetComponentAndID(RenderEvent renderEvent) { - _instance = component; + if (TryFindComponent(renderEvent.Frames, ComponentId, out var id, out var component)) + { + _instance = component; + ComponentId = id; + } + else + { + throw new InvalidOperationException("Component instance not found at expected position in render tree."); + } + } + + private bool TryFindComponent(RenderTreeFrameCollection framesCollection, int parentComponentId, out int componentId, out TComponent component) + { + var result = false; + componentId = -1; + component = default!; + + var frames = framesCollection[parentComponentId]; + + for (var i = 0; i < frames.Count; i++) + { + ref var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Component) + { + if (frame.Component is TComponent c) + { + componentId = frame.ComponentId; + component = c; + result = true; + break; + } + + if (TryFindComponent(framesCollection, frame.ComponentId, out componentId, out component)) + { + result = true; + break; + } + } + } + + return result; } } } diff --git a/src/bunit.web/Rendering/RenderedComponentActivator.cs b/src/bunit.web/Rendering/RenderedComponentActivator.cs new file mode 100644 index 000000000..48ec22242 --- /dev/null +++ b/src/bunit.web/Rendering/RenderedComponentActivator.cs @@ -0,0 +1,35 @@ +using System; + +using Microsoft.AspNetCore.Components; + +namespace Bunit.Rendering +{ + /// + /// Represents a rendered component activator for bUnit.web. + /// + public sealed class RenderedComponentActivator : IRenderedComponentActivator + { + private readonly IServiceProvider _services; + + /// + /// Creates an instance of the activator. + /// + public RenderedComponentActivator(IServiceProvider services) + { + _services = services; + } + + /// + public IRenderedFragmentBase CreateRenderedFragment(int componentId) + => new RenderedFragment(componentId, _services); + + /// + public IRenderedComponentBase CreateRenderedComponent(int componentId) where TComponent : IComponent + => new RenderedComponent(componentId, _services); + + /// + public IRenderedComponentBase CreateRenderedComponent(int componentId, TComponent component, RenderTreeFrameCollection componentFrames) + where TComponent : IComponent + => new RenderedComponent(componentId, component, componentFrames, _services); + } +} diff --git a/src/bunit.web/Rendering/RenderedFragment.cs b/src/bunit.web/Rendering/RenderedFragment.cs index 7c45fee5c..7016f9156 100644 --- a/src/bunit.web/Rendering/RenderedFragment.cs +++ b/src/bunit.web/Rendering/RenderedFragment.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -14,35 +15,35 @@ namespace Bunit.Rendering { - /// - /// Represents an abstract with base functionality. - /// - public class RenderedFragment : IRenderedFragment, IRenderEventHandler + /// + internal class RenderedFragment : IRenderedFragment { private readonly object _markupAccessLock = new object(); - private readonly ILogger _logger; + [SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Instance is owned by the service provider and should not be disposed here.")] + private readonly HtmlParser _htmlParser; + private string _markup = string.Empty; private string? _snapshotMarkup; + private INodeList? _firstRenderNodes; private INodeList? _latestRenderNodes; private INodeList? _snapshotNodes; - private string _markup; - private bool _componentDisposed; - - private HtmlParser HtmlParser { get; } /// /// Gets the first rendered markup. /// - protected string FirstRenderMarkup { get; } + protected string FirstRenderMarkup { get; private set; } = string.Empty; /// - public ITestRenderer Renderer { get; } + public event Action? OnAfterRender; /// - public IServiceProvider Services { get; } + public event Action? OnMarkupUpdated; + + /// + public bool IsDisposed { get; private set; } /// - public int ComponentId { get; } + public int ComponentId { get; protected set; } /// public string Markup @@ -51,15 +52,23 @@ public string Markup { EnsureComponentNotDisposed(); - // The lock ensures that we cannot read the _markup and _latestRenderNodes - // field while it is being updated + // The lock prevents a race condition between the renderers thread + // and the test frameworks thread, where one might be reading the Markup + // while the other is updating it due to async code in a rendered component. lock (_markupAccessLock) { + // Volatile read is necessary to ensure the updated markup + // is available across CPU cores. Without it, the pointer to the + // markup string can be stored in a CPUs register and not + // get updated when another CPU changes the string. return Volatile.Read(ref _markup); } } } + /// + public int RenderCount { get; protected set; } + /// public INodeList Nodes { @@ -71,120 +80,113 @@ public INodeList Nodes lock (_markupAccessLock) { if (_latestRenderNodes is null) - _latestRenderNodes = HtmlParser.Parse(Markup); + _latestRenderNodes = _htmlParser.Parse(Markup); + return _latestRenderNodes; } } } /// - public event Action? OnMarkupUpdated; - - /// - public event Action? OnAfterRender; - - /// - public int RenderCount { get; private set; } + public IServiceProvider Services { get; } - /// - /// Creates an instance of the class. - /// - public RenderedFragment(IServiceProvider services, int componentId) + internal RenderedFragment(int componentId, IServiceProvider service) { - if (services is null) - throw new ArgumentNullException(nameof(services)); - - _logger = services.CreateLogger(); - HtmlParser = services.GetRequiredService(); - Renderer = services.GetRequiredService(); - Services = services; ComponentId = componentId; - _markup = RetrieveLatestMarkupFromRenderer(); - FirstRenderMarkup = _markup; - Renderer.AddRenderEventHandler(this); - RenderCount = 1; + Services = service; + _htmlParser = Services.GetRequiredService(); } /// - public void SaveSnapshot() + public IReadOnlyList GetChangesSinceFirstRender() { - _snapshotNodes = null; - _snapshotMarkup = Markup; + if (_firstRenderNodes is null) + _firstRenderNodes = _htmlParser.Parse(FirstRenderMarkup); + + return Nodes.CompareTo(_firstRenderNodes); } /// public IReadOnlyList GetChangesSinceSnapshot() { if (_snapshotMarkup is null) - throw new InvalidOperationException($"No snapshot exists to compare with. Call {nameof(SaveSnapshot)} to create one."); + throw new InvalidOperationException($"No snapshot exists to compare with. Call {nameof(SaveSnapshot)}() to create one."); if (_snapshotNodes is null) - _snapshotNodes = HtmlParser.Parse(_snapshotMarkup); + _snapshotNodes = _htmlParser.Parse(_snapshotMarkup); return Nodes.CompareTo(_snapshotNodes); } /// - public IReadOnlyList GetChangesSinceFirstRender() + public void SaveSnapshot() { - if (_firstRenderNodes is null) - _firstRenderNodes = HtmlParser.Parse(FirstRenderMarkup); - return Nodes.CompareTo(_firstRenderNodes); + _snapshotNodes = null; + _snapshotMarkup = Markup; } - private string RetrieveLatestMarkupFromRenderer() => Htmlizer.GetHtml(Renderer, ComponentId); - - Task IRenderEventHandler.Handle(RenderEvent renderEvent) + void IRenderedFragmentBase.OnRender(RenderEvent renderEvent) { - if (renderEvent.DidComponentDispose(ComponentId)) - { - HandleComponentDisposed(); - } - else if (renderEvent.DidComponentRender(ComponentId)) + if (IsDisposed) + return; + + var (rendered, changed, disposed) = renderEvent.GetRenderStatus(this); + + if (disposed) { - HandleComponentRender(renderEvent); + ((IDisposable)this).Dispose(); + return; } - return Task.CompletedTask; - } - private void HandleComponentRender(RenderEvent renderEvent) - { - _logger.LogDebug(new EventId(1, nameof(HandleComponentRender)), $"Received a new render where component {ComponentId} did render."); + // The lock prevents a race condition between the renderers thread + // and the test frameworks thread, where one might be reading the Markup + // while the other is updating it due to async code in a rendered component. + lock (_markupAccessLock) + { - RenderCount++; + if (rendered) + { + OnRender(renderEvent); + RenderCount++; + } - // First notify derived types, e.g. queried AngleSharp collections or elements - // that the markup has changed and they should rerun their queries. - HandleChangesToMarkup(renderEvent); + if (changed) + { + UpdateMarkup(renderEvent.Frames); + } + } - // Then it is safe to tell anybody waiting on updates or changes to the rendered fragment - // that they can redo their assertions or continue processing. - OnAfterRender?.Invoke(); + // The order here is important, since consumers of the events + // expect that markup has indeed changed when OnAfterRender is invoked + // (assuming there are markup changes) + if (changed) + OnMarkupUpdated?.Invoke(); + if (rendered) + OnAfterRender?.Invoke(); } - private void HandleChangesToMarkup(RenderEvent renderEvent) + protected void UpdateMarkup(RenderTreeFrameCollection framesCollection) { - if (renderEvent.HasMarkupChanges(ComponentId)) + // The lock prevents a race condition between the renderers thread + // and the test frameworks thread, where one might be reading the Markup + // while the other is updating it due to async code in a rendered component. + lock (_markupAccessLock) { - _logger.LogDebug(new EventId(1, nameof(HandleChangesToMarkup)), $"Received a new render where the markup of component {ComponentId} changed."); + _latestRenderNodes = null; + var newMarkup = Htmlizer.GetHtml(ComponentId, framesCollection); - // The lock ensures that latest nodes is always based on the latest rendered markup. - lock (_markupAccessLock) - { - _latestRenderNodes = null; - _markup = RetrieveLatestMarkupFromRenderer(); - } + // Volatile write is necessary to ensure the updated markup + // is available across CPU cores. Without it, the pointer to the + // markup string can be stored in a CPUs register and not + // get updated when another CPU changes the string. + Volatile.Write(ref _markup, newMarkup); - OnMarkupUpdated?.Invoke(); + if (RenderCount == 1) + FirstRenderMarkup = newMarkup; } } - private void HandleComponentDisposed() - { - _logger.LogDebug(new EventId(1, nameof(HandleChangesToMarkup)), $"Received a new render where the component {ComponentId} was disposed."); - _componentDisposed = true; - Renderer.RemoveRenderEventHandler(this); - } + protected virtual void OnRender(RenderEvent renderEvent) { } /// /// Ensures that the underlying component behind the @@ -192,8 +194,16 @@ private void HandleComponentDisposed() /// protected void EnsureComponentNotDisposed() { - if (_componentDisposed) + if (IsDisposed) throw new ComponentDisposedException(ComponentId); } + + void IDisposable.Dispose() + { + IsDisposed = true; + _markup = string.Empty; + OnAfterRender = null; + FirstRenderMarkup = string.Empty; + } } } diff --git a/src/bunit.web/TestContext.cs b/src/bunit.web/TestContext.cs index cbdf6db65..be27493fe 100644 --- a/src/bunit.web/TestContext.cs +++ b/src/bunit.web/TestContext.cs @@ -27,10 +27,7 @@ public TestContext() /// Parameters to pass to the component when it is rendered /// The rendered public IRenderedComponent RenderComponent(params ComponentParameter[] parameters) where TComponent : IComponent - { - var renderResult = Renderer.RenderComponent(parameters); - return new RenderedComponent(Services, renderResult.ComponentId, renderResult.Component); - } + => TestRendererExtensions.RenderComponent(Renderer, parameters); /// /// Instantiates and performs a first render of a component of type . @@ -39,14 +36,6 @@ public IRenderedComponent RenderComponent(params Compone /// The ComponentParameterBuilder action to add type safe parameters to pass to the component when it is rendered /// The rendered public virtual IRenderedComponent RenderComponent(Action> parameterBuilder) where TComponent : IComponent - { - if (parameterBuilder is null) - throw new ArgumentNullException(nameof(parameterBuilder)); - - var builder = new ComponentParameterBuilder(); - parameterBuilder(builder); - var renderResult = Renderer.RenderComponent(builder.Build()); - return new RenderedComponent(Services, renderResult.ComponentId, renderResult.Component); - } + => TestRendererExtensions.RenderComponent(Renderer, parameterBuilder); } } diff --git a/tests/bunit.core.tests/ComponentParameterFactoryTest.cs b/tests/bunit.core.tests/ComponentParameterFactoryTest.cs index 150cbf806..f025a737b 100644 --- a/tests/bunit.core.tests/ComponentParameterFactoryTest.cs +++ b/tests/bunit.core.tests/ComponentParameterFactoryTest.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; + using Bunit.Rendering; using Bunit.TestAssets.SampleComponents; using Bunit.TestDoubles.JSInterop; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Shouldly; @@ -17,6 +19,12 @@ namespace Bunit { public class ComponentParameterFactoryTest : TestContext { + string GetMarkupFromRenderFragment(RenderFragment renderFragment) + { + return ((IRenderedFragment)Renderer.RenderFragment(renderFragment)).Markup; + } + + [Fact(DisplayName = "All types of parameters are correctly assigned to component on render")] public void Test005() { @@ -43,8 +51,8 @@ public void Test005() Should.Throw(async () => await instance.NonGenericCallback.InvokeAsync(EventArgs.Empty)).Message.ShouldBe("NonGenericCallback"); Should.Throw(async () => await instance.GenericCallback.InvokeAsync(EventArgs.Empty)).Message.ShouldBe("GenericCallback"); - new RenderedFragment(Services, Renderer.RenderFragment(instance.ChildContent!)).Markup.ShouldBe(nameof(ChildContent)); - new RenderedFragment(Services, Renderer.RenderFragment(instance.OtherContent!)).Markup.ShouldBe(nameof(AllTypesOfParams.OtherContent)); + GetMarkupFromRenderFragment(instance.ChildContent!).ShouldBe(nameof(ChildContent)); + GetMarkupFromRenderFragment(instance.OtherContent!).ShouldBe(nameof(AllTypesOfParams.OtherContent)); Should.Throw(() => instance.ItemTemplate!("")(new RenderTreeBuilder())).Message.ShouldBe("ItemTemplate"); } @@ -82,8 +90,8 @@ public void Test002() instance.RegularParam.ShouldBe("some value"); Should.Throw(async () => await instance.NonGenericCallback.InvokeAsync(EventArgs.Empty)).Message.ShouldBe("NonGenericCallback"); Should.Throw(async () => await instance.GenericCallback.InvokeAsync(EventArgs.Empty)).Message.ShouldBe("GenericCallback"); - new RenderedFragment(Services, Renderer.RenderFragment(instance.ChildContent!)).Markup.ShouldBe(nameof(ChildContent)); - new RenderedFragment(Services, Renderer.RenderFragment(instance.OtherContent!)).Markup.ShouldBe(nameof(AllTypesOfParams.OtherContent)); + GetMarkupFromRenderFragment(instance.ChildContent!).ShouldBe(nameof(ChildContent)); + GetMarkupFromRenderFragment(instance.OtherContent!).ShouldBe(nameof(AllTypesOfParams.OtherContent)); Should.Throw(() => instance.ItemTemplate!("")(new RenderTreeBuilder())).Message.ShouldBe("ItemTemplate"); } diff --git a/tests/bunit.core.tests/Rendering/TestRendererTest.cs b/tests/bunit.core.tests/Rendering/TestRendererTest.cs index 71fa31491..65dbbd4ab 100644 --- a/tests/bunit.core.tests/Rendering/TestRendererTest.cs +++ b/tests/bunit.core.tests/Rendering/TestRendererTest.cs @@ -1,104 +1,429 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; - +using Bunit.Extensions; using Bunit.TestAssets.SampleComponents; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; +using static Bunit.ComponentParameterFactory; namespace Bunit.Rendering { + public class NoChildNoParams : ComponentBase + { + public const string MARKUP = "hello world"; + protected override void BuildRenderTree(RenderTreeBuilder builder) + => builder.AddMarkupContent(0, MARKUP); + } + + public class ThrowsDuringSetParams : ComponentBase + { + public static readonly InvalidOperationException EXCEPTION = + new InvalidOperationException("THROWS ON PURPOSE"); + + public override Task SetParametersAsync(ParameterView parameters) => throw EXCEPTION; + } + + public class HasParams : ComponentBase + { + [Parameter] public string? Value { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddMarkupContent(0, Value); + builder.AddContent(1, ChildContent); + } + } + + public class RenderTrigger : ComponentBase + { + [Parameter] public string? Value { get; set; } + + public Task Trigger() => InvokeAsync(StateHasChanged); + public Task TriggerWithValue(string value) + { + Value = value; + return InvokeAsync(StateHasChanged); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.AddMarkupContent(0, Value); + } + } + + public class ToggleChild : ComponentBase + { + private bool _showing = true; + + [Parameter] public RenderFragment? ChildContent { get; set; } + + public Task DisposeChild() + { + _showing = false; + return InvokeAsync(StateHasChanged); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (_showing) + builder.AddContent(0, ChildContent); + } + } + public class TestRendererTest { - private static readonly ServiceProvider ServiceProvider = new ServiceCollection().BuildServiceProvider(); + private TestServiceProvider Services { get; } - [Fact(DisplayName = "Renderer notifies handlers of render events")] - public async Task Test001() + public TestRendererTest() { - // Arrange - using var sut = new TestRenderer(ServiceProvider, NullLoggerFactory.Instance); - var handler = new MockRenderEventHandler(completeHandleTaskSynchronously: true); - sut.AddRenderEventHandler(handler); + Services = new TestServiceProvider(); + Services.AddDefaultTestContextServices(); + Services.AddSingleton(); + } - // Act #1 - var cut = sut.RenderComponent(Array.Empty()); + [Fact(DisplayName = "RenderFragment re-throws exception from component")] + public void Test004() + { + var sut = Services.GetRequiredService(); + RenderFragment thowingFragment = b => { b.OpenComponent(0); b.CloseComponent(); }; - // Assert #1 - handler.ReceivedEvents.Count.ShouldBe(1); + Should.Throw(() => sut.RenderFragment(thowingFragment)) + .Message.ShouldBe(ThrowsDuringSetParams.EXCEPTION.Message); + } - // Act #2 - await sut.Dispatcher.InvokeAsync(() => cut.Component.SetParametersAsync(ParameterView.Empty)); + [Fact(DisplayName = "RenderComponent re-throws exception from component")] + public void Test003() + { + var sut = Services.GetRequiredService(); - // Assert #2 - handler.ReceivedEvents.Count.ShouldBe(2); + Should.Throw(() => sut.RenderComponent()) + .Message.ShouldBe(ThrowsDuringSetParams.EXCEPTION.Message); } - [Fact(DisplayName = "Multiple handlers can be added to the Renderer")] + [Fact(DisplayName = "Can render fragment without children and no parameters")] + public void Test001() + { + const string MARKUP = "

hello world

"; + var sut = Services.GetRequiredService(); + + var cut = (IRenderedFragment)sut.RenderFragment(builder => builder.AddMarkupContent(0, MARKUP)); + + cut.RenderCount.ShouldBe(1); + cut.Markup.ShouldBe(MARKUP); + } + + [Fact(DisplayName = "Can render component without children and no parameters")] public void Test002() { - using var sut = new TestRenderer(ServiceProvider, NullLoggerFactory.Instance); - var handler1 = new MockRenderEventHandler(completeHandleTaskSynchronously: true); - var handler2 = new MockRenderEventHandler(completeHandleTaskSynchronously: true); + var sut = Services.GetRequiredService(); - sut.AddRenderEventHandler(handler1); - sut.AddRenderEventHandler(handler2); + var cut = sut.RenderComponent(); - sut.RenderComponent(Array.Empty()); - handler1.ReceivedEvents.Count.ShouldBe(1); - handler2.ReceivedEvents.Count.ShouldBe(1); + cut.RenderCount.ShouldBe(1); + cut.Markup.ShouldBe(NoChildNoParams.MARKUP); + cut.Instance.ShouldBeOfType(); } - [Fact(DisplayName = "Handler is not invoked if removed from Renderer")] - public void Test003() + [Fact(DisplayName = "Can render component with parameters")] + public void Test005() + { + const string VALUE = "FOO BAR"; + var sut = Services.GetRequiredService(); + + var cut = sut.RenderComponent((nameof(HasParams.Value), VALUE)); + + cut.Instance.Value.ShouldBe(VALUE); + } + + [Fact(DisplayName = "Can render component with child component")] + public void Test006() + { + const string PARENT_VALUE = "PARENT"; + const string CHILD_VALUE = "CHILD"; + + var sut = Services.GetRequiredService(); + + var cut = sut.RenderComponent( + (nameof(HasParams.Value), PARENT_VALUE), + ChildContent((nameof(HasParams.Value), CHILD_VALUE)) + ); + + cut.Markup.ShouldStartWith(PARENT_VALUE); + cut.Markup.ShouldEndWith(CHILD_VALUE); + } + + [Fact(DisplayName = "Rendered component gets RenderCount updated on re-render")] + public async Task Test010() + { + var sut = Services.GetRequiredService(); + + var cut = sut.RenderComponent(); + + cut.RenderCount.ShouldBe(1); + + await cut.Instance.Trigger(); + + cut.RenderCount.ShouldBe(2); + } + + [Fact(DisplayName = "Rendered component gets Markup updated on re-render")] + public async Task Test011() + { + // arrange + const string EXPECTED = "NOW VALUE"; + var sut = Services.GetRequiredService(); + var cut = sut.RenderComponent(); + + cut.RenderCount.ShouldBe(1); + + // act + await cut.Instance.TriggerWithValue(EXPECTED); + + // assert + cut.RenderCount.ShouldBe(2); + cut.Markup.ShouldBe(EXPECTED); + } + + [Fact(DisplayName = "FindComponent returns first component nested inside another rendered component")] + public void Test020() + { + // arrange + const string PARENT_VALUE = "PARENT"; + const string CHILD_VALUE = "CHILD"; + + var sut = Services.GetRequiredService(); + + var cut = sut.RenderComponent( + (nameof(HasParams.Value), PARENT_VALUE), + ChildContent((nameof(HasParams.Value), CHILD_VALUE)) + ); + + // act + var childCut = (IRenderedComponent)sut.FindComponent(cut); + + // assert + childCut.Markup.ShouldBe(CHILD_VALUE); + childCut.RenderCount.ShouldBe(1); + } + + [Fact(DisplayName = "FindComponent throws if parentComponent parameter is null")] + public void Test021() + { + var sut = Services.GetRequiredService(); + + Should.Throw(() => sut.FindComponent(null!)); + } + + [Fact(DisplayName = "FindComponent throws if component is not found")] + public void Test022() + { + var sut = Services.GetRequiredService(); + var cut = sut.RenderComponent(); + + Should.Throw(() => sut.FindComponent(cut)); + } + + [Fact(DisplayName = "FindComponents returns all components nested inside another rendered component")] + public void Test030() { - using var sut = new TestRenderer(ServiceProvider, NullLoggerFactory.Instance); - var handler1 = new MockRenderEventHandler(completeHandleTaskSynchronously: true); - var handler2 = new MockRenderEventHandler(completeHandleTaskSynchronously: true); - sut.AddRenderEventHandler(handler1); - sut.AddRenderEventHandler(handler2); + // arrange + const string GRAND_PARENT_VALUE = nameof(GRAND_PARENT_VALUE); + const string PARENT_VALUE = nameof(PARENT_VALUE); + const string CHILD_VALUE = nameof(CHILD_VALUE); - sut.RemoveRenderEventHandler(handler1); + var sut = Services.GetRequiredService(); - sut.RenderComponent(Array.Empty()); - handler1.ReceivedEvents.ShouldBeEmpty(); - handler2.ReceivedEvents.Count.ShouldBe(1); + var cut = sut.RenderComponent( + (nameof(HasParams.Value), GRAND_PARENT_VALUE), + ChildContent( + (nameof(HasParams.Value), PARENT_VALUE), + ChildContent( + (nameof(HasParams.Value), CHILD_VALUE) + ) + ) + ); + + // act + var childCuts = sut.FindComponents(cut) + .Cast>() + .ToArray(); + + // assert + childCuts[0].Markup.ShouldBe(PARENT_VALUE + CHILD_VALUE); + childCuts[0].RenderCount.ShouldBe(1); + + childCuts[1].Markup.ShouldBe(CHILD_VALUE); + childCuts[1].RenderCount.ShouldBe(1); } - class MockRenderEventHandler : IRenderEventHandler + [Fact(DisplayName = "FindComponents throws if parentComponent parameter is null")] + public void Test031() { - private TaskCompletionSource _handleTask = new TaskCompletionSource(); - private readonly bool _completeHandleTaskSynchronously; + var sut = Services.GetRequiredService(); - public List ReceivedEvents { get; set; } = new List(); + Should.Throw(() => sut.FindComponents(null!)); + } - public MockRenderEventHandler(bool completeHandleTaskSynchronously) - { - if (completeHandleTaskSynchronously) - SetCompleted(); - _completeHandleTaskSynchronously = completeHandleTaskSynchronously; - } + [Fact(DisplayName = "Retrieved rendered child component with FindComponent gets updated on re-render")] + public async Task Test040() + { + var sut = Services.GetRequiredService(); + + var parent = sut.RenderComponent( + ChildContent() + ); + + // act + var cut = (IRenderedComponent)sut.FindComponent(parent); + + cut.RenderCount.ShouldBe(1); + + await cut.Instance.TriggerWithValue("X"); + + cut.RenderCount.ShouldBe(2); + cut.Markup.ShouldBe("X"); + } + + [Fact(DisplayName = "Retrieved rendered child component with FindComponents gets updated on re-render")] + public async Task Test041() + { + var sut = Services.GetRequiredService(); + + var parent = sut.RenderComponent( + ChildContent() + ); + + // act + var cut = (IRenderedComponent)sut.FindComponents(parent).Single(); + + cut.RenderCount.ShouldBe(1); + + await cut.Instance.TriggerWithValue("X"); + + cut.RenderCount.ShouldBe(2); + cut.Markup.ShouldBe("X"); + } + + [Fact(DisplayName = "Rendered component updates on re-renders from child components with changes in render tree")] + public async Task Test050() + { + // arrange + var sut = Services.GetRequiredService(); - public Task Handle(RenderEvent renderEvent) - { - ReceivedEvents.Add(renderEvent); - return _handleTask.Task; - } + var cut = sut.RenderComponent( + ChildContent() + ); + var child = (IRenderedComponent)sut.FindComponent(cut); + + // act + await child.Instance.TriggerWithValue("X"); + + // assert + cut.RenderCount.ShouldBe(2); + cut.Markup.ShouldBe("X"); + } + + [Fact(DisplayName = "When component is disposed by renderer, getting Markup throws and IsDisposed returns true")] + public async Task Test060() + { + // arrange + var sut = Services.GetRequiredService(); + + var cut = sut.RenderComponent( + ChildContent() + ); + var child = (IRenderedComponent)sut.FindComponent(cut); + + // act + await cut.Instance.DisposeChild(); + + // assert + child.IsDisposed.ShouldBeTrue(); + Should.Throw(() => child.Markup); + } + + [Fact(DisplayName = "Rendered component updates itself if a child's child is disposed")] + public async Task Test061() + { + // arrange + var sut = Services.GetRequiredService(); + + var cut = sut.RenderComponent( + ChildContent( + ChildContent() + ) + ); + var child = (IRenderedComponent)sut.FindComponent(cut); + var childChild = (IRenderedComponent)sut.FindComponent(cut); + + // act + await child.Instance.DisposeChild(); + + // assert + childChild.IsDisposed.ShouldBeTrue(); + cut.Markup.ShouldBe(string.Empty); + } + + [Fact(DisplayName = "When test renderer is disposed, so is all rendered components")] + public void Test070() + { + var sut = (TestRenderer)Services.GetRequiredService(); + var cut = sut.RenderComponent(); + + sut.Dispose(); + + cut.IsDisposed.ShouldBeTrue(); + } + + [Fact(DisplayName = "Can render component that awaits uncompleted task in OnInitializedAsync")] + public void Test100() + { + using var ctx = new TestContext(); + var tcs = new TaskCompletionSource(); + + var cut = ctx.RenderComponent(parameters => + parameters.Add(p => p.EitherOr, tcs.Task) + ); + + cut.Find("h1").TextContent.ShouldBe("FIRST"); + } + + [Fact(DisplayName = "Can render component that awaits yielding task in OnInitializedAsync")] + public void Test101() + { + using var ctx = new TestContext(); + + var cut = ctx.RenderComponent(parameters => + parameters.Add(p => p.EitherOr, Task.Delay(1)) + ); + + + var h1 = cut.Find("h1"); + + cut.WaitForAssertion(() => h1.TextContent.ShouldBe("SECOND")); + } + + [Fact(DisplayName = "Can render component that awaits completed task in OnInitializedAsync")] + public void Test102() + { + using var ctx = new TestContext(); - public void SetCompleted() - { - if (_completeHandleTaskSynchronously) - return; + var cut = ctx.RenderComponent(parameters => + parameters.Add(p => p.EitherOr, Task.CompletedTask) + ); - var existing = _handleTask; - _handleTask = new TaskCompletionSource(); - existing.SetResult(null); - } + cut.Find("h1").TextContent.ShouldBe("SECOND"); } } } diff --git a/tests/bunit.testassets/SampleComponents/AsyncRenderOfSubComponentDuringInit.razor b/tests/bunit.testassets/SampleComponents/AsyncRenderOfSubComponentDuringInit.razor new file mode 100644 index 000000000..819bfd7df --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/AsyncRenderOfSubComponentDuringInit.razor @@ -0,0 +1,27 @@ + + @if (_eitherOr) + { + + + + } + else + { + + + + } + + +@code +{ + private bool _eitherOr = true; + + [Parameter] public Task EitherOr { get; set; } + + protected override async Task OnInitializedAsync() + { + await EitherOr; + _eitherOr = false; + } +} diff --git a/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs b/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs index c1f901d04..3d0cebe95 100644 --- a/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs +++ b/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs @@ -99,16 +99,6 @@ public void CanTriggerKeyPressEvents() ); } - [Fact(DisplayName = "After KeyPress event is triggered, contains keys passed to KeyPress", Skip = "Issue #46 - https://github.com/egil/razor-components-testing-library/issues/46")] - public void Test001() - { - var cut = RenderComponent(); - - cut.Find("input").KeyPress("abc"); - - cut.Find("input").GetAttribute("value").ShouldBe("abc"); - } - [Fact] public void CanAddAndRemoveEventHandlersDynamically() { diff --git a/tests/bunit.web.tests/EventDispatchExtensions/EventBubblingTest.cs b/tests/bunit.web.tests/EventDispatchExtensions/EventBubblingTest.cs deleted file mode 100644 index 3c8ff5818..000000000 --- a/tests/bunit.web.tests/EventDispatchExtensions/EventBubblingTest.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Bunit.TestAssets.SampleComponents; - -using Shouldly; - -using Xunit; - -namespace Bunit.EventDispatchExtensions -{ - public class EventBubblingTest : TestContext - { - [Fact(DisplayName = "When clicking on an element with an event handler, " + - "event handlers higher up the DOM tree is also triggered", Skip = "fix with #119")] - public void Test001() - { - var cut = RenderComponent(); - - cut.Find("span").Click(); - - 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", Skip = "fix with #119")] - public void Test002() - { - var cut = RenderComponent(); - - cut.Find("button").Click(); - - cut.Instance.SpanClickCount.ShouldBe(0); - cut.Instance.HeaderClickCount.ShouldBe(1); - } - } -} diff --git a/tests/bunit.xunit.tests/SampleComponents/SkipRazorComponent.cs b/tests/bunit.xunit.tests/SampleComponents/SkipRazorComponent.cs deleted file mode 100644 index 50f6398a8..000000000 --- a/tests/bunit.xunit.tests/SampleComponents/SkipRazorComponent.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; - -namespace Bunit.SampleComponents -{ - public class SkipRazorComponent : TestComponentBase - { - protected override void BuildRenderTree(RenderTreeBuilder builder) - { - builder.OpenComponent(10); - builder.AddAttribute(11, nameof(Fixture.Description), "FIXTURE SKIPPED"); - builder.AddAttribute(11, nameof(Fixture.Skip), "SKIP ME"); - builder.AddAttribute(12, nameof(Fixture.ChildContent), (RenderFragment)Content); - builder.AddAttribute(13, nameof(Fixture.Test), (Action)TestMethod); - builder.CloseComponent(); - } - - private void TestMethod(Fixture fixture) { } - - private void Content(RenderTreeBuilder builder) { } - } -} diff --git a/tests/run-tests.ps1 b/tests/run-tests.ps1 new file mode 100644 index 000000000..95e7ff1e1 --- /dev/null +++ b/tests/run-tests.ps1 @@ -0,0 +1,19 @@ +$maxRuns = $args[0] +$mode = $args[1] +$filter = $args[2] + +dotnet build ..\bunit.sln -c $mode --nologo + +for ($num = 1 ; $num -le $maxRuns ; $num++) +{ + Write-Output "### TEST RUN $num ###" + + if($filter) + { + dotnet test ..\bunit.sln -c $mode --no-restore --no-build --blame-hang --blame-hang-timeout 100s -f net5.0 --nologo --filter $filter + } + else + { + dotnet test ..\bunit.sln -c $mode --no-restore --no-build --blame-hang --blame-hang-timeout 100s -f net5.0 --nologo + } +}