diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
index 288e541d..742c6cad 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
@@ -11,6 +11,10 @@
+
+
+
+
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionReaperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionReaperTests.cs
new file mode 100644
index 00000000..076aa933
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionReaperTests.cs
@@ -0,0 +1,86 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
+
+/// Unit tests for . We drive the reaper
+/// directly through the internal ReapOnceAsync entry point and skew
+/// to simulate the passage of time —
+/// no real wall-clock waits.
+public sealed class BrowseSessionReaperTests
+{
+ private static BrowseSessionReaper NewReaper(BrowseSessionRegistry registry) =>
+ new(registry, NullLogger.Instance);
+
+ [Fact]
+ public async Task ReapOnceAsync_evicts_idle_session()
+ {
+ var registry = new BrowseSessionRegistry();
+ var session = new FakeBrowseSession
+ {
+ LastUsedUtc = DateTime.UtcNow - TimeSpan.FromMinutes(3),
+ };
+ registry.Register(session);
+ var reaper = NewReaper(registry);
+
+ await reaper.ReapOnceAsync(CancellationToken.None);
+
+ registry.TryGet(session.Token, out _).ShouldBeFalse();
+ session.Disposed.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task ReapOnceAsync_preserves_recent_session()
+ {
+ var registry = new BrowseSessionRegistry();
+ var session = new FakeBrowseSession { LastUsedUtc = DateTime.UtcNow };
+ registry.Register(session);
+ var reaper = NewReaper(registry);
+
+ await reaper.ReapOnceAsync(CancellationToken.None);
+
+ registry.TryGet(session.Token, out _).ShouldBeTrue();
+ session.Disposed.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ReapOnceAsync_handles_already_removed_session()
+ {
+ var registry = new BrowseSessionRegistry();
+ var session = new FakeBrowseSession
+ {
+ LastUsedUtc = DateTime.UtcNow - TimeSpan.FromMinutes(3),
+ };
+ registry.Register(session);
+ // Race the reaper: pull the entry out before the loop calls TryRemove.
+ registry.TryRemove(session.Token, out _).ShouldBeTrue();
+ var reaper = NewReaper(registry);
+
+ // No exception, no double dispose. Because we removed the session ourselves
+ // without disposing it, Disposed should still be false.
+ await reaper.ReapOnceAsync(CancellationToken.None);
+
+ session.Disposed.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ReapOnceAsync_continues_when_one_session_dispose_throws()
+ {
+ var registry = new BrowseSessionRegistry();
+ var staleAt = DateTime.UtcNow - TimeSpan.FromMinutes(3);
+ var bad = new FakeBrowseSession { LastUsedUtc = staleAt, ThrowOnDispose = true };
+ var good = new FakeBrowseSession { LastUsedUtc = staleAt };
+ registry.Register(bad);
+ registry.Register(good);
+ var reaper = NewReaper(registry);
+
+ // The dispose exception is swallowed; the second session must still be reaped + disposed.
+ await Should.NotThrowAsync(() => reaper.ReapOnceAsync(CancellationToken.None));
+
+ registry.TryGet(bad.Token, out _).ShouldBeFalse();
+ registry.TryGet(good.Token, out _).ShouldBeFalse();
+ good.Disposed.ShouldBeTrue();
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionRegistryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionRegistryTests.cs
new file mode 100644
index 00000000..2e2dca9b
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowseSessionRegistryTests.cs
@@ -0,0 +1,62 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
+
+/// Unit tests for 's lookup, removal and
+/// concurrent-registration behaviour.
+public sealed class BrowseSessionRegistryTests
+{
+ [Fact]
+ public void Register_then_TryGet_returns_session()
+ {
+ var registry = new BrowseSessionRegistry();
+ var session = new FakeBrowseSession();
+
+ registry.Register(session);
+ var found = registry.TryGet(session.Token, out var got);
+
+ found.ShouldBeTrue();
+ got.ShouldBeSameAs((IBrowseSession)session);
+ }
+
+ [Fact]
+ public void TryGet_unknown_returns_false()
+ {
+ var registry = new BrowseSessionRegistry();
+
+ registry.TryGet(Guid.NewGuid(), out var got).ShouldBeFalse();
+ got.ShouldBeNull();
+ }
+
+ [Fact]
+ public void TryRemove_then_TryGet_returns_false()
+ {
+ var registry = new BrowseSessionRegistry();
+ var session = new FakeBrowseSession();
+ registry.Register(session);
+
+ registry.TryRemove(session.Token, out var removed).ShouldBeTrue();
+ removed.ShouldBeSameAs((IBrowseSession)session);
+ registry.TryGet(session.Token, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Concurrent_Register_from_many_tasks_all_visible_in_Snapshot()
+ {
+ var registry = new BrowseSessionRegistry();
+ const int count = 50;
+ var sessions = Enumerable.Range(0, count).Select(_ => new FakeBrowseSession()).ToArray();
+
+ var tasks = sessions.Select(s => Task.Run(() => registry.Register(s))).ToArray();
+ await Task.WhenAll(tasks);
+
+ var snapshot = registry.Snapshot();
+ snapshot.Count.ShouldBe(count);
+ var snapshotTokens = snapshot.Select(x => x.Token).OrderBy(g => g).ToArray();
+ var expectedTokens = sessions.Select(s => s.Token).OrderBy(g => g).ToArray();
+ snapshotTokens.ShouldBe(expectedTokens);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs
new file mode 100644
index 00000000..d69d8322
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs
@@ -0,0 +1,142 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
+
+/// Unit tests for — driver-type dispatch
+/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
+/// timeout enforcement.
+public sealed class BrowserSessionServiceTests
+{
+ private static BrowserSessionService NewService(
+ BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
+ new(browsers, registry, NullLogger.Instance);
+
+ [Fact]
+ public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
+ {
+ var registry = new BrowseSessionRegistry();
+ var service = NewService(registry, new FakeDriverBrowser("Known"));
+
+ var result = await service.OpenAsync("Unknown", "{}", CancellationToken.None);
+
+ result.Ok.ShouldBeFalse();
+ result.Token.ShouldBe(Guid.Empty);
+ result.Message.ShouldNotBeNull();
+ result.Message!.ShouldContain("Unknown");
+ }
+
+ [Fact]
+ public async Task OpenAsync_happy_path_returns_token_and_registers()
+ {
+ var registry = new BrowseSessionRegistry();
+ var session = new FakeBrowseSession();
+ var browser = new FakeDriverBrowser("Galaxy")
+ {
+ OpenHandler = (_, _) => Task.FromResult(session),
+ };
+ var service = NewService(registry, browser);
+
+ var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
+
+ result.Ok.ShouldBeTrue();
+ result.Message.ShouldBeNull();
+ result.Token.ShouldNotBe(Guid.Empty);
+ result.Token.ShouldBe(session.Token);
+ registry.TryGet(result.Token, out var registered).ShouldBeTrue();
+ registered.ShouldBeSameAs((IBrowseSession)session);
+ }
+
+ [Fact]
+ public async Task OpenAsync_swallows_driver_throws_returns_Ok_false()
+ {
+ var registry = new BrowseSessionRegistry();
+ var browser = new FakeDriverBrowser("Galaxy")
+ {
+ OpenHandler = (_, _) => throw new InvalidOperationException("boom"),
+ };
+ var service = NewService(registry, browser);
+
+ var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
+
+ result.Ok.ShouldBeFalse();
+ result.Token.ShouldBe(Guid.Empty);
+ result.Message.ShouldNotBeNull();
+ result.Message!.ShouldContain("boom");
+ }
+
+ [Fact]
+ public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException()
+ {
+ var registry = new BrowseSessionRegistry();
+ var service = NewService(registry);
+
+ await Should.ThrowAsync(
+ () => service.RootAsync(Guid.NewGuid(), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task RootAsync_invokes_session_Root()
+ {
+ var registry = new BrowseSessionRegistry();
+ IReadOnlyList expected = new[]
+ {
+ new BrowseNode("ns=2;s=A", "A", BrowseNodeKind.Folder, true),
+ new BrowseNode("ns=2;s=B", "B", BrowseNodeKind.Leaf, false),
+ };
+ var session = new FakeBrowseSession { RootHandler = _ => Task.FromResult(expected) };
+ registry.Register(session);
+ var service = NewService(registry);
+
+ var actual = await service.RootAsync(session.Token, CancellationToken.None);
+
+ actual.ShouldBe(expected);
+ }
+
+ [Fact]
+ public async Task RootAsync_enforces_PerCallTimeout()
+ {
+ var registry = new BrowseSessionRegistry();
+ var session = new FakeBrowseSession
+ {
+ // Awaits the linked CTS — the service must cancel it via PerCallTimeout (20s).
+ RootHandler = ct => Task.Delay(TimeSpan.FromSeconds(40), ct)
+ .ContinueWith>(_ => Array.Empty(), ct),
+ };
+ registry.Register(session);
+ var service = NewService(registry);
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ await Should.ThrowAsync(
+ () => service.RootAsync(session.Token, CancellationToken.None));
+ sw.Stop();
+
+ // Real timeout is 20s; allow generous slack but cap well below the 40s task delay.
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(35));
+ }
+
+ [Fact]
+ public async Task CloseAsync_removes_and_disposes_session()
+ {
+ var registry = new BrowseSessionRegistry();
+ var session = new FakeBrowseSession();
+ var browser = new FakeDriverBrowser("Galaxy")
+ {
+ OpenHandler = (_, _) => Task.FromResult(session),
+ };
+ var service = NewService(registry, browser);
+ var opened = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
+ opened.Ok.ShouldBeTrue();
+
+ await service.CloseAsync(opened.Token);
+
+ registry.TryGet(opened.Token, out _).ShouldBeFalse();
+ session.Disposed.ShouldBeTrue();
+
+ // Unknown token is a no-op.
+ await Should.NotThrowAsync(() => service.CloseAsync(Guid.NewGuid()));
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeBrowseSession.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeBrowseSession.cs
new file mode 100644
index 00000000..1930dc41
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeBrowseSession.cs
@@ -0,0 +1,50 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
+
+/// Test double for used by the registry, reaper,
+/// and service tests. All three operations delegate to caller-supplied handlers so each
+/// test can shape behaviour; records that it ran and can be
+/// instructed to throw via .
+internal sealed class FakeBrowseSession : IBrowseSession
+{
+ ///
+ public Guid Token { get; } = Guid.NewGuid();
+
+ /// Mutable so tests can rewind the timestamp into the reaper's eviction window.
+ public DateTime LastUsedUtc { get; set; } = DateTime.UtcNow;
+
+ /// True once has run to completion.
+ public bool Disposed { get; private set; }
+
+ /// When true, throws to exercise the reaper's
+ /// best-effort dispose path.
+ public bool ThrowOnDispose { get; set; }
+
+ // Suppress CS0649: handlers are test seams — some tests leave them null intentionally.
+#pragma warning disable CS0649
+ public Func>>? RootHandler;
+ public Func>>? ExpandHandler;
+ public Func>>? AttributesHandler;
+#pragma warning restore CS0649
+
+ ///
+ public Task> RootAsync(CancellationToken ct)
+ => RootHandler?.Invoke(ct) ?? Task.FromResult>(Array.Empty());
+
+ ///
+ public Task> ExpandAsync(string nodeId, CancellationToken ct)
+ => ExpandHandler?.Invoke(nodeId, ct) ?? Task.FromResult>(Array.Empty());
+
+ ///
+ public Task> AttributesAsync(string nodeId, CancellationToken ct)
+ => AttributesHandler?.Invoke(nodeId, ct) ?? Task.FromResult>(Array.Empty());
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ if (ThrowOnDispose) throw new InvalidOperationException("dispose-failed");
+ Disposed = true;
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeDriverBrowser.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeDriverBrowser.cs
new file mode 100644
index 00000000..c9795793
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeDriverBrowser.cs
@@ -0,0 +1,22 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
+
+/// Test double for . The constructor sets
+/// ; delegates to the caller-supplied
+/// or returns a fresh .
+internal sealed class FakeDriverBrowser(string driverType) : IDriverBrowser
+{
+ ///
+ public string DriverType { get; } = driverType;
+
+ /// Override for ; if null, a fresh
+ /// is returned.
+#pragma warning disable CS0649
+ public Func>? OpenHandler;
+#pragma warning restore CS0649
+
+ ///
+ public Task OpenAsync(string configJson, CancellationToken ct)
+ => OpenHandler?.Invoke(configJson, ct) ?? Task.FromResult(new FakeBrowseSession());
+}