using System.Reflection; using System.Runtime.ExceptionServices; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers; // BL0006: the test renderer needs RenderTree types (same suppression as RawBrowseModalStaleOpenTests). #pragma warning disable BL0006 /// /// R04 L4: a browse session that finishes opening after its picker (or the browse modal) went away is /// closed at once instead of living until the reaper's idle TTL. /// [Trait("Category", "Unit")] public sealed class BrowseOpenGuardTests : IDisposable { private readonly List _renderers = new(); public void Dispose() { foreach (var r in _renderers) r.Dispose(); } [Fact] public async Task Guard_closes_an_orphaned_session_and_leaves_a_live_one() { var svc = new RecordingBrowserService(); var token = Guid.NewGuid(); BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(true, null, token), hostGone: false, svc).ShouldBeFalse(); svc.Closed.ShouldBeEmpty(); BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(true, null, token), hostGone: true, svc).ShouldBeTrue(); await svc.WaitForCloseAsync(); svc.Closed.ShouldBe([token]); } [Fact] public void Guard_never_closes_a_failed_open_and_swallows_a_failing_close() { var svc = new RecordingBrowserService(); BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(false, "refused", Guid.Empty), hostGone: true, svc).ShouldBeTrue(); svc.Closed.ShouldBeEmpty(); var throwing = new RecordingBrowserService { ThrowOnClose = true }; Should.NotThrow(() => BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(true, null, Guid.NewGuid()), true, throwing)); } [Fact] public async Task Picker_disposed_mid_open_closes_the_session_it_was_handed() { var gate = new TaskCompletionSource(); var svc = new RecordingBrowserService { Open = () => gate.Task }; var picker = NewPicker(svc); var open = InvokeOpen(picker); await picker.DisposeAsync(); var token = Guid.NewGuid(); gate.SetResult(new BrowseOpenResult(true, null, token)); await open; await svc.WaitForCloseAsync(); svc.Closed.ShouldBe([token]); Token(picker).ShouldBe(Guid.Empty); } [Fact] public async Task Picker_still_open_adopts_the_session() { // Positive control for the test above: without the dispose the token is adopted, not closed. var token = Guid.NewGuid(); var svc = new RecordingBrowserService { Open = () => Task.FromResult(new BrowseOpenResult(true, null, token)) }; var picker = NewPicker(svc); await InvokeOpen(picker); Token(picker).ShouldBe(token); svc.Closed.ShouldBeEmpty(); } [Fact] public void Every_browse_host_guards_its_open() { foreach (var path in new[] { "AbCipAddressPickerBody.razor", "FOCASAddressPickerBody.razor", "GalaxyAddressPickerBody.razor", "OpcUaClientAddressPickerBody.razor", "TwinCATAddressPickerBody.razor", "SqlAddressPickerBody.razor", }) { var src = AdminUiSource.Read("Components", "Shared", "Drivers", "Pickers", path); src.ShouldContain("BrowseOpenGuard.CloseIfOrphaned(result, _disposed, BrowserService)", customMessage: path); src.ShouldContain("_disposed = true;", customMessage: path); } var modal = AdminUiSource.Read("Components", "Shared", "Raw", "RawBrowseModal.razor"); modal.ShouldContain("BrowseOpenGuard.CloseIfOrphaned(result, _disposed || IsStale(generation), BrowserService)"); } private OpcUaClientAddressPickerBody NewPicker(IBrowserSessionService svc) { var picker = new OpcUaClientAddressPickerBody(); typeof(OpcUaClientAddressPickerBody).GetProperty("BrowserService", BindingFlags.NonPublic | BindingFlags.Instance)! .SetValue(picker, svc); var renderer = new InertRenderer(); _renderers.Add(renderer); renderer.Attach(picker); return picker; } private static Task InvokeOpen(OpcUaClientAddressPickerBody picker) => (Task)typeof(OpcUaClientAddressPickerBody).GetMethod("OpenBrowseAsync", BindingFlags.NonPublic | BindingFlags.Instance)! .Invoke(picker, null)!; private static Guid Token(OpcUaClientAddressPickerBody picker) => (Guid)typeof(OpcUaClientAddressPickerBody).GetField("_token", BindingFlags.NonPublic | BindingFlags.Instance)! .GetValue(picker)!; private sealed class RecordingBrowserService : IBrowserSessionService { private readonly TaskCompletionSource _closed = new(TaskCreationOptions.RunContinuationsAsynchronously); public List Closed { get; } = new(); public bool ThrowOnClose { get; init; } public Func> Open { get; init; } = () => Task.FromResult(new BrowseOpenResult(false, "n/a", Guid.Empty)); public Task WaitForCloseAsync() => _closed.Task.WaitAsync(TimeSpan.FromSeconds(10)); public bool CanBrowse(string driverType, string configJson) => true; public Task OpenAsync(string driverType, string configJson, CancellationToken ct) => Open(); public Task> RootAsync(Guid token, CancellationToken ct) => throw new NotImplementedException(); public Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException(); public Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException(); public Task RequestRebirthAsync(Guid token, string scope, CancellationToken ct) => throw new NotImplementedException(); public bool CanRequestRebirth(Guid token) => false; public Task CloseAsync(Guid token) { if (ThrowOnClose) throw new InvalidOperationException("remote gone"); lock (Closed) Closed.Add(token); _closed.TrySetResult(); return Task.CompletedTask; } } private sealed class InertRenderer() : Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance) { public override Dispatcher Dispatcher { get; } = new InlineDispatcher(); public void Attach(IComponent component) => AssignRootComponentId(component); protected override void HandleException(Exception exception) => ExceptionDispatchInfo.Capture(exception).Throw(); protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; } private sealed class InlineDispatcher : Dispatcher { public override bool CheckAccess() => true; public override Task InvokeAsync(Action workItem) { workItem(); return Task.CompletedTask; } public override Task InvokeAsync(Func workItem) => workItem(); public override Task InvokeAsync(Func workItem) => Task.FromResult(workItem()); public override Task InvokeAsync(Func> workItem) => workItem(); } }