using Bunit; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.JSInterop; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; /// /// lmxopcua#902: closing a tab with the script editor open ended the circuit, and the editor's /// DisposeAsync JS call then failed with , which escaped and was logged /// at Error as an unhandled circuit exception. A JS-interop failure caused by the circuit already being gone is /// the expected outcome of disposal: it must not escape and must not log above Debug. A genuine /// from a live page is still surfaced (Warning). /// [Trait("Category", "Unit")] public sealed class MonacoEditorDisposeTests : BunitContext { private readonly CapturingLoggerProvider _logs = new(); public MonacoEditorDisposeTests() { Services.AddLogging(b => b.AddProvider(_logs).SetMinimumLevel(LogLevel.Trace)); JSInterop.SetupVoid("MonacoBlazor.createEditor", _ => true).SetVoidResult(); } public static TheoryData GoneCircuitFailures => new() { nameof(TaskCanceledException), nameof(JSDisconnectedException), nameof(ObjectDisposedException), }; private static Exception Make(string name) => name switch { nameof(TaskCanceledException) => new TaskCanceledException(), nameof(JSDisconnectedException) => new JSDisconnectedException("circuit gone"), nameof(ObjectDisposedException) => new ObjectDisposedException("JSRuntime"), _ => throw new ArgumentOutOfRangeException(nameof(name)), }; [Theory] [MemberData(nameof(GoneCircuitFailures))] public async Task Dispose_on_a_gone_circuit_is_quiet(string failure) { var dispose = JSInterop.SetupVoid("MonacoBlazor.dispose", _ => true); dispose.SetException(Make(failure)); Render(p => p.Add(x => x.ShowToolbar, false)); // Disposing the render tree is what the circuit does when the tab closes. await Should.NotThrowAsync(() => DisposeComponentsAsync()); JSInterop.VerifyInvoke("MonacoBlazor.dispose"); // positive control: the failing call really ran _logs.Entries.Where(e => e.Level > LogLevel.Debug).ShouldBeEmpty(); _logs.Entries.ShouldContain(e => e.Level == LogLevel.Debug && e.Message.Contains(failure)); } [Fact] public async Task A_real_js_fault_on_dispose_is_still_logged() { JSInterop.SetupVoid("MonacoBlazor.dispose", _ => true).SetException(new JSException("monaco blew up")); Render(p => p.Add(x => x.ShowToolbar, false)); await Should.NotThrowAsync(() => DisposeComponentsAsync()); _logs.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Exception is JSException); } private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception); private sealed class CapturingLoggerProvider : ILoggerProvider { public List Entries { get; } = new(); public ILogger CreateLogger(string categoryName) => new Logger(this); public void Dispose() { } private sealed class Logger(CapturingLoggerProvider owner) : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { lock (owner.Entries) owner.Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); } } } }