From f14bbade3f8a362684446ae9b17a2b2244322727 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 28 Aug 2020 01:23:24 +0000 Subject: [PATCH 01/11] new test renderer prototype --- src/bunit.core/Rendering/NewTestRenderer.cs | 134 +++++++++ .../Rendering/Internal/NewHtmlizer.cs | 282 ++++++++++++++++++ .../Rendering/NewTestRendererTest.cs | 142 +++++++++ .../Rendering/TestRendererTest.cs | 37 +++ .../AsyncRenderOfSubComponentDuringInit.razor | 27 ++ 5 files changed, 622 insertions(+) create mode 100644 src/bunit.core/Rendering/NewTestRenderer.cs create mode 100644 src/bunit.web/Rendering/Internal/NewHtmlizer.cs create mode 100644 tests/bunit.core.tests/Rendering/NewTestRendererTest.cs create mode 100644 tests/bunit.testassets/SampleComponents/AsyncRenderOfSubComponentDuringInit.razor diff --git a/src/bunit.core/Rendering/NewTestRenderer.cs b/src/bunit.core/Rendering/NewTestRenderer.cs new file mode 100644 index 000000000..fdb3e9114 --- /dev/null +++ b/src/bunit.core/Rendering/NewTestRenderer.cs @@ -0,0 +1,134 @@ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bunit.Rendering +{ + public interface IRenderedComponent + { + void OnRender(IReadOnlyDictionary> currentRenderTree); + } + + public interface IRenderedComponentActivator + { + IRenderedComponent CreateRenderedComponent(int componentId); + IRenderedComponent CreateRenderedComponent(int componentId) where T : IComponent; + } + + public sealed class NewTestRenderer : Renderer + { + private readonly ILogger _logger; + private readonly IRenderedComponentActivator _activator; + private Exception? _unhandledException; + private readonly Dictionary _renderedComponents = new Dictionary(); + + public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault(); + + public NewTestRenderer(IRenderedComponentActivator activator, IServiceProvider services, ILoggerFactory loggerFactory) : base(services, loggerFactory) + { + _logger = loggerFactory.CreateLogger(); + _activator = activator; + } + + public T RenderFragment(RenderFragment renderFragment) where T : IRenderedComponent + { + T renderedComponent = default; + var task = Dispatcher.InvokeAsync(() => + { + var root = new WrapperComponent(renderFragment); + var rootComponentId = AssignRootComponentId(root); + renderedComponent = (T)_activator.CreateRenderedComponent(rootComponentId); + _renderedComponents.Add(rootComponentId, renderedComponent); + root.Render(); + }); + + Debug.Assert(task.IsCompleted, "The render task did not complete as expected"); + AssertNoUnhandledExceptions(); + + return renderedComponent!; + } + + public T RenderComponent(ComponentParameter[] componentParameters) + where T : IRenderedComponent + where TComponent : IComponent + { + T renderedComponent = default; + var task = Dispatcher.InvokeAsync(() => + { + var root = new WrapperComponent(componentParameters.ToComponentRenderFragment()); + var rootComponentId = AssignRootComponentId(root); + renderedComponent = (T)_activator.CreateRenderedComponent(rootComponentId); + _renderedComponents.Add(rootComponentId, renderedComponent); + root.Render(); + }); + + Debug.Assert(task.IsCompleted, "The render task did not complete as expected"); + AssertNoUnhandledExceptions(); + + return renderedComponent!; + } + + protected override void HandleException(Exception exception) + => _unhandledException = exception; + + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) + { + for (var i = 0; i < renderBatch.UpdatedComponents.Count; i++) + { + ref var update = ref renderBatch.UpdatedComponents.Array[i]; + var id = update.ComponentId; + if (_renderedComponents.TryGetValue(id, out var rc)) + { + var crtf = GetRenderTreeFromRoot(id); + + rc.OnRender(crtf); + } + } + + return Task.CompletedTask; + } + + private Dictionary> GetRenderTreeFromRoot(int rootComponentId) + { + var result = new Dictionary>(); + GetRenderTreeFramesInternal(rootComponentId); + return result; + + void GetRenderTreeFramesInternal(int componentId) + { + var frames = GetCurrentRenderTreeFrames(componentId); + result.Add(componentId, frames); + + for (var i = 0; i < frames.Count; i++) + { + ref var frame = ref frames.Array[i]; + if (frame.FrameType == RenderTreeFrameType.Component) + { + GetRenderTreeFramesInternal(frame.ComponentId); + } + } + } + } + + private void AssertNoUnhandledExceptions() + { + if (_unhandledException is { } unhandled) + { + _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(); + } + } + } +} diff --git a/src/bunit.web/Rendering/Internal/NewHtmlizer.cs b/src/bunit.web/Rendering/Internal/NewHtmlizer.cs new file mode 100644 index 000000000..07bb2e9ff --- /dev/null +++ b/src/bunit.web/Rendering/Internal/NewHtmlizer.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Encodings.Web; + +using Bunit.Rendering; + +using Microsoft.AspNetCore.Components.RenderTree; + +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 = "")] + public static class HewHtmlizer + { + private static readonly HtmlEncoder HtmlEncoder = HtmlEncoder.Default; + + private static readonly HashSet SelfClosingElements = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr" + }; + + 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"; + + public static bool IsBlazorAttribute(string attributeName) + => 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(int componentId, IReadOnlyDictionary> frameSet) + { + var context = new HtmlRenderingContext(frameSet); + 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); + } + + private static int RenderFrames(HtmlRenderingContext context, ArrayRange frames, int position, int maxElements) + { + var nextPosition = position; + var endPosition = position + maxElements; + while (position < endPosition) + { + nextPosition = RenderCore(context, frames, position); + if (position == nextPosition) + { + throw new InvalidOperationException("We didn't consume any input."); + } + position = nextPosition; + } + + return nextPosition; + } + + private static int RenderCore( + HtmlRenderingContext context, + ArrayRange frames, + int position) + { + ref var frame = ref frames.Array[position]; + switch (frame.FrameType) + { + case RenderTreeFrameType.Element: + return RenderElement(context, frames, position); + case RenderTreeFrameType.Attribute: + throw new InvalidOperationException($"Attributes should only be encountered within {nameof(RenderElement)}"); + case RenderTreeFrameType.Text: + context.Result.Add(HtmlEncoder.Encode(frame.TextContent)); + return ++position; + case RenderTreeFrameType.Markup: + context.Result.Add(frame.MarkupContent); + return ++position; + case RenderTreeFrameType.Component: + return RenderChildComponent(context, frames, position); + case RenderTreeFrameType.Region: + return RenderFrames(context, frames, position + 1, frame.RegionSubtreeLength - 1); + case RenderTreeFrameType.ElementReferenceCapture: + case RenderTreeFrameType.ComponentReferenceCapture: + return ++position; + default: + throw new InvalidOperationException($"Invalid element frame type '{frame.FrameType}'."); + } + } + + private static int RenderChildComponent( + HtmlRenderingContext context, + ArrayRange frames, + int position) + { + ref var frame = ref frames.Array[position]; + var childFrames = context.GetRenderTreeFrames(frame.ComponentId); + RenderFrames(context, childFrames, 0, childFrames.Count); + return position + frame.ComponentSubtreeLength; + } + + private static int RenderElement( + HtmlRenderingContext context, + ArrayRange frames, + int position) + { + ref var frame = ref frames.Array[position]; + var result = context.Result; + result.Add("<"); + result.Add(frame.ElementName); + var afterAttributes = RenderAttributes(context, frames, position + 1, frame.ElementSubtreeLength - 1, out var capturedValueAttribute); + + // When we see an