using System.Reflection; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw; /// /// Issue #604 / review R3-M1: the error boundary must live INSIDE the interactive scope. These tests /// drive the real through a real (no bUnit in this /// project): a child that throws while rendering is contained (nothing reaches the renderer's unhandled /// path, i.e. the circuit survives), "Try again" and a location change both recover, and disposing the /// boundary unsubscribes from the navigation manager. A structural guard also pins that every /// interactive page actually wraps its body. /// #pragma warning disable BL0006 // Renderer lives in Microsoft.AspNetCore.Components.RenderTree; subclassing it is the only way to host a // component without a bUnit dependency (same approach as RawBrowseModalStaleOpenTests). public sealed class PageErrorBoundaryTests : IDisposable { private readonly FakeNav _nav = new(); private readonly TestRenderer _renderer; public PageErrorBoundaryTests() { var sp = new ServiceCollection() .AddSingleton(_nav) .AddSingleton() .BuildServiceProvider(); _renderer = new TestRenderer(sp); } [Fact] public async Task A_throw_in_the_page_body_is_contained_and_Try_again_recovers() { Thrower.ShouldThrow = false; await _renderer.RenderRootAsync(); _renderer.Text.ShouldContain("page body ok"); // positive control: the healthy body renders Thrower.ShouldThrow = true; await _renderer.Dispatcher.InvokeAsync(() => _renderer.RerenderThrower()); _renderer.Unhandled.ShouldBeEmpty(); // contained: the circuit would have survived _renderer.Text.ShouldContain("This page hit an unexpected error."); _renderer.Text.ShouldContain("boom"); _renderer.Text.ShouldNotContain("page body ok"); // "Try again" is a live handler, not dead static markup. Thrower.ShouldThrow = false; _renderer.LastOnClickHandlerId.ShouldNotBe(0UL); await _renderer.Dispatcher.InvokeAsync(() => _renderer.DispatchEventAsync(_renderer.LastOnClickHandlerId, null, new EventArgs())); _renderer.Text.ShouldContain("page body ok"); _renderer.Unhandled.ShouldBeEmpty(); } [Fact] public async Task A_location_change_recovers_a_tripped_boundary() { Thrower.ShouldThrow = false; await _renderer.RenderRootAsync(); Thrower.ShouldThrow = true; await _renderer.Dispatcher.InvokeAsync(() => _renderer.RerenderThrower()); _renderer.Text.ShouldContain("This page hit an unexpected error."); Thrower.ShouldThrow = false; await _renderer.Dispatcher.InvokeAsync(() => _nav.NavigateTo("http://localhost/other")); await _renderer.Dispatcher.InvokeAsync(() => Task.CompletedTask); // let the queued Recover run _renderer.Text.ShouldContain("page body ok"); } [Fact] public async Task Disposing_the_boundary_unsubscribes_from_navigation() { Thrower.ShouldThrow = false; await _renderer.RenderRootAsync(); FakeNav.HandlerCount(_nav).ShouldBe(1); // positive control: it did subscribe _renderer.Dispose(); FakeNav.HandlerCount(_nav).ShouldBe(0); } [Fact] public void Every_interactive_page_wraps_its_body_in_PageErrorBoundary_and_the_layout_does_not_pretend_to() { var components = ComponentsDir(); var offenders = new List(); foreach (var file in Directory.EnumerateFiles(Path.Combine(components, "Pages"), "*.razor", SearchOption.AllDirectories)) { var text = File.ReadAllText(file); if (!text.Contains("@rendermode")) { continue; } // Same text the router compiles: the wrapper must open before, and close after, the markup. var open = text.IndexOf("", StringComparison.Ordinal); var close = text.LastIndexOf("", StringComparison.Ordinal); var code = text.IndexOf("\n@code", StringComparison.Ordinal); var ok = open > -1 && close > open && (code < 0 || close < code); if (!ok) { offenders.Add(Path.GetRelativePath(components, file).Replace('\\', '/')); } } offenders.ShouldBeEmpty("interactive pages without a PageErrorBoundary wrapper: " + string.Join(", ", offenders)); // The static layout is not an ancestor of interactive pages: a boundary there is inert. File.ReadAllText(Path.Combine(components, "Layout", "MainLayout.razor")).ShouldNotContain(" _renderer.Dispose(); private static string ComponentsDir() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { var candidate = Path.Combine(dir.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI", "Components"); if (Directory.Exists(candidate)) { return candidate; } dir = dir.Parent; } throw new DirectoryNotFoundException("AdminUI Components directory not found above the test binaries."); } /// Throws from its render when is set. private sealed class Thrower : ComponentBase { public static volatile bool ShouldThrow; protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { if (ShouldThrow) { throw new InvalidOperationException("boom"); } builder.AddMarkupContent(0, "page body ok"); } public void Poke() => StateHasChanged(); } private sealed class Host : ComponentBase { internal Thrower? Child; protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder) { builder.OpenComponent(0); builder.AddAttribute(1, nameof(PageErrorBoundary.ChildContent), (RenderFragment)(b => { b.OpenComponent(0); b.AddComponentReferenceCapture(1, o => Child = (Thrower)o); b.CloseComponent(); })); builder.CloseComponent(); } } private sealed class FakeNav : NavigationManager { public FakeNav() => Initialize("http://localhost/", "http://localhost/"); protected override void NavigateToCore(string uri, NavigationOptions options) { Uri = uri; NotifyLocationChanged(isInterceptedLink: false); } public static int HandlerCount(NavigationManager nav) { var field = typeof(NavigationManager).GetField("_locationChanged", BindingFlags.Instance | BindingFlags.NonPublic); field.ShouldNotBeNull("NavigationManager's LocationChanged backing field was renamed; update this probe"); return (field.GetValue(nav) as Delegate)?.GetInvocationList().Length ?? 0; } } private sealed class NullBoundaryLogger : IErrorBoundaryLogger { public ValueTask LogErrorAsync(Exception exception) => ValueTask.CompletedTask; } private sealed class TestRenderer(IServiceProvider sp) : Renderer(sp, NullLoggerFactory.Instance) { private Host? _host; private readonly List _text = new(); public List Unhandled { get; } = new(); public ulong LastOnClickHandlerId { get; private set; } public string Text => string.Concat(_text); public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault(); public async Task RenderRootAsync() { await Dispatcher.InvokeAsync(async () => { _host = (Host)InstantiateComponent(typeof(Host)); var id = AssignRootComponentId(_host); await RenderRootComponentAsync(id); }); } public Task RerenderThrower() { _host!.Child!.Poke(); return Task.CompletedTask; } protected override void HandleException(Exception exception) => Unhandled.Add(exception); protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) { // Each batch replaces the visible text when it carries any: the swap between ChildContent and // ErrorContent is a whole-subtree change, so its frames are all in that batch. var batchText = new List(); var frames = renderBatch.ReferenceFrames; for (var i = 0; i < frames.Count; i++) { ref readonly var f = ref frames.Array[i]; switch (f.FrameType) { case RenderTreeFrameType.Text: batchText.Add(f.TextContent); break; case RenderTreeFrameType.Markup: batchText.Add(f.MarkupContent); break; case RenderTreeFrameType.Attribute when f.AttributeEventHandlerId != 0: LastOnClickHandlerId = f.AttributeEventHandlerId; break; } } if (batchText.Count > 0) { _text.Clear(); _text.AddRange(batchText); } return Task.CompletedTask; } } }