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));
}
}
}
}