Result:
@_built
@@ -30,16 +76,63 @@
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback
CurrentAddressChanged { get; set; }
+ /// Driver type used to open a universal browse session (defaults to AbCip).
+ [Parameter] public string DriverType { get; set; } = "AbCip";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetConfigJson { get; set; } = () => "{}";
private string _tagName = "";
private int _elementIndex = 0;
private bool _useIndex = false;
private string _built = "";
- protected override void OnInitialized()
+ private Guid _token = Guid.Empty;
+ private bool _opening;
+ private bool _canBrowse;
+ private string? _openError;
+
+ protected override async Task OnInitializedAsync()
{
+ var auth = await AuthState.GetAuthenticationStateAsync();
+ var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
+ // CanBrowse constructs a throwaway driver — evaluate once, not per render.
+ _canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
_built = Build();
- _ = CurrentAddressChanged.InvokeAsync(_built);
+ await CurrentAddressChanged.InvokeAsync(_built);
+ }
+
+ private async Task OpenBrowseAsync()
+ {
+ _opening = true; _openError = null; StateHasChanged();
+ try
+ {
+ var json = GetConfigJson() ?? "{}";
+ var result = await BrowserService.OpenAsync(DriverType, json, default);
+ if (result.Ok) _token = result.Token;
+ else _openError = result.Message;
+ }
+ finally { _opening = false; StateHasChanged(); }
+ }
+
+ private async Task RefreshBrowseAsync()
+ {
+ await CloseBrowseAsync();
+ await OpenBrowseAsync();
+ }
+
+ private async Task CloseBrowseAsync()
+ {
+ var t = _token; _token = Guid.Empty; StateHasChanged();
+ if (t != Guid.Empty) await BrowserService.CloseAsync(t);
+ }
+
+ private async Task OnTreeSelectAsync(BrowseNode node)
+ {
+ // Folders fire the same callback — commit only on a leaf (the captured NodeId is the CIP tag path).
+ if (node.Kind != BrowseNodeKind.Leaf) return;
+ _tagName = node.NodeId;
+ _useIndex = false;
+ await OnChangedAsync();
}
private async Task OnChangedAsync()
@@ -54,4 +147,17 @@
return "";
return _useIndex ? $"{_tagName}[{_elementIndex}]" : _tagName;
}
+
+ private string TruncatedError() =>
+ _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
+
+ public ValueTask DisposeAsync()
+ {
+ if (_token != Guid.Empty)
+ {
+ // Fire-and-forget — don't block circuit teardown on a slow capture.
+ _ = BrowserService.CloseAsync(_token);
+ }
+ return ValueTask.CompletedTask;
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
index 81c64ff3..1f1cfe2d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/FOCASAddressPickerBody.razor
@@ -1,5 +1,17 @@
-@* Static FOCAS address builder: parameter group + parameter ID → axis:5 *@
+@* FOCAS address picker:
+ 1. Static parameter group + parameter ID → axis:5 (always available).
+ 2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
+ device model captured by the universal DiscoveryDriverBrowser. FOCAS opens take a few
+ seconds longer than other drivers — its FixedTree cache fills post-connect and the browser
+ runs an UntilStable settle loop; the _opening spinner covers the wait. *@
+@implements IAsyncDisposable
+@using Microsoft.AspNetCore.Authorization
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
+@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
+@inject IBrowserSessionService BrowserService
+@inject AuthenticationStateProvider AuthState
+@inject IAuthorizationService AuthorizationService
+@if (_canBrowse)
+{
+
+ @if (_token == Guid.Empty)
+ {
+
+ }
+ else
+ {
+ Browser open
+
+
+ }
+ @if (_openError is not null) { @TruncatedError() }
+
+
+ @if (_token != Guid.Empty)
+ {
+
+
Snapshot taken at open/refresh time — click Refresh to re-read the machine.
+
+
+ }
+}
+
Result:
@_built
@@ -26,15 +74,63 @@
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback
CurrentAddressChanged { get; set; }
+ /// Driver type used to open a universal browse session (defaults to FOCAS).
+ [Parameter] public string DriverType { get; set; } = "FOCAS";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetConfigJson { get; set; } = () => "{}";
private string _group = "axis";
private int _parameterId = 0;
private string _built = "";
- protected override void OnInitialized()
+ private Guid _token = Guid.Empty;
+ private bool _opening;
+ private bool _canBrowse;
+ private string? _openError;
+
+ protected override async Task OnInitializedAsync()
{
+ var auth = await AuthState.GetAuthenticationStateAsync();
+ var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
+ // CanBrowse constructs a throwaway driver — evaluate once, not per render.
+ _canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
_built = FocasAddressBuilder.Build(_group, _parameterId);
- _ = CurrentAddressChanged.InvokeAsync(_built);
+ await CurrentAddressChanged.InvokeAsync(_built);
+ }
+
+ private async Task OpenBrowseAsync()
+ {
+ _opening = true; _openError = null; StateHasChanged();
+ try
+ {
+ var json = GetConfigJson() ?? "{}";
+ var result = await BrowserService.OpenAsync(DriverType, json, default);
+ if (result.Ok) _token = result.Token;
+ else _openError = result.Message;
+ }
+ finally { _opening = false; StateHasChanged(); }
+ }
+
+ private async Task RefreshBrowseAsync()
+ {
+ await CloseBrowseAsync();
+ await OpenBrowseAsync();
+ }
+
+ private async Task CloseBrowseAsync()
+ {
+ var t = _token; _token = Guid.Empty; StateHasChanged();
+ if (t != Guid.Empty) await BrowserService.CloseAsync(t);
+ }
+
+ private async Task OnTreeSelectAsync(BrowseNode node)
+ {
+ // Folders fire the same callback — commit only on a leaf. The captured NodeId is the
+ // driver FullName (FixedTree address), which the group/id builder cannot reconstruct, so
+ // commit it directly rather than through OnChangedAsync.
+ if (node.Kind != BrowseNodeKind.Leaf) return;
+ _built = node.NodeId;
+ await CurrentAddressChanged.InvokeAsync(_built);
}
private async Task OnChangedAsync()
@@ -42,4 +138,17 @@
_built = FocasAddressBuilder.Build(_group, _parameterId);
await CurrentAddressChanged.InvokeAsync(_built);
}
+
+ private string TruncatedError() =>
+ _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
+
+ public ValueTask DisposeAsync()
+ {
+ if (_token != Guid.Empty)
+ {
+ // Fire-and-forget — don't block circuit teardown on a slow capture.
+ _ = BrowserService.CloseAsync(_token);
+ }
+ return ValueTask.CompletedTask;
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
index c2e0e187..5c4152c3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/TwinCATAddressPickerBody.razor
@@ -1,4 +1,14 @@
-@* Static TwinCAT address builder: ADS variable name (free text) → verbatim *@
+@* TwinCAT address picker:
+ 1. Static ADS variable name (free text) → verbatim (always available).
+ 2. (DriverOperator-gated, when the driver supports online discovery) Browse the ADS symbol
+ tree captured by the universal DiscoveryDriverBrowser. *@
+@implements IAsyncDisposable
+@using Microsoft.AspNetCore.Authorization
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
+@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
+@inject IBrowserSessionService BrowserService
+@inject AuthenticationStateProvider AuthState
+@inject IAuthorizationService AuthorizationService
+@if (_canBrowse)
+{
+
+ @if (_token == Guid.Empty)
+ {
+
+ }
+ else
+ {
+ Browser open
+
+
+ }
+ @if (_openError is not null) { @TruncatedError() }
+
+
+ @if (_token != Guid.Empty)
+ {
+
+
Snapshot taken at open/refresh time — click Refresh to re-read the controller.
+
+
+ }
+}
+
Result:
@_built
@@ -19,14 +65,60 @@
@code {
[Parameter] public string CurrentAddress { get; set; } = "";
[Parameter] public EventCallback
CurrentAddressChanged { get; set; }
+ /// Driver type used to open a universal browse session (defaults to TwinCAT).
+ [Parameter] public string DriverType { get; set; } = "TwinCAT";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetConfigJson { get; set; } = () => "{}";
private string _varName = "";
private string _built = "";
- protected override void OnInitialized()
+ private Guid _token = Guid.Empty;
+ private bool _opening;
+ private bool _canBrowse;
+ private string? _openError;
+
+ protected override async Task OnInitializedAsync()
{
+ var auth = await AuthState.GetAuthenticationStateAsync();
+ var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
+ // CanBrowse constructs a throwaway driver — evaluate once, not per render.
+ _canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
_built = _varName;
- _ = CurrentAddressChanged.InvokeAsync(_built);
+ await CurrentAddressChanged.InvokeAsync(_built);
+ }
+
+ private async Task OpenBrowseAsync()
+ {
+ _opening = true; _openError = null; StateHasChanged();
+ try
+ {
+ var json = GetConfigJson() ?? "{}";
+ var result = await BrowserService.OpenAsync(DriverType, json, default);
+ if (result.Ok) _token = result.Token;
+ else _openError = result.Message;
+ }
+ finally { _opening = false; StateHasChanged(); }
+ }
+
+ private async Task RefreshBrowseAsync()
+ {
+ await CloseBrowseAsync();
+ await OpenBrowseAsync();
+ }
+
+ private async Task CloseBrowseAsync()
+ {
+ var t = _token; _token = Guid.Empty; StateHasChanged();
+ if (t != Guid.Empty) await BrowserService.CloseAsync(t);
+ }
+
+ private async Task OnTreeSelectAsync(BrowseNode node)
+ {
+ // Folders fire the same callback — commit only on a leaf (the captured NodeId is the ADS symbol path).
+ if (node.Kind != BrowseNodeKind.Leaf) return;
+ _varName = node.NodeId;
+ await OnChangedAsync();
}
private async Task OnChangedAsync()
@@ -34,4 +126,17 @@
_built = _varName;
await CurrentAddressChanged.InvokeAsync(_built);
}
+
+ private string TruncatedError() =>
+ _openError is null ? "" : (_openError.Length > 60 ? _openError[..60] + "…" : _openError);
+
+ public ValueTask DisposeAsync()
+ {
+ if (_token != Guid.Empty)
+ {
+ // Fire-and-forget — don't block circuit teardown on a slow capture.
+ _ = BrowserService.CloseAsync(_token);
+ }
+ return ValueTask.CompletedTask;
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor
index 27328ab8..066a1030 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor
@@ -34,13 +34,19 @@
CurrentAddress="@_pickerAddress"
OnPickAddress="@OnAddressPicked">
+ CurrentAddressChanged="@((s) => _pickerAddress = s)"
+ DriverType="@DriverType"
+ GetConfigJson="@GetDriverConfigJson" />
}
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// DriverType of the selected driver — lets browse-capable pickers open a universal session.
+ [Parameter] public string DriverType { get; set; } = "";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}";
private AbCipTagConfigModel _m = new();
private string? _lastConfigJson;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor
index 8f770103..9a657d1a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor
@@ -41,6 +41,10 @@
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// DriverType of the selected driver — lets browse-capable pickers open a universal session.
+ [Parameter] public string DriverType { get; set; } = "";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}";
private AbLegacyTagConfigModel _m = new();
private string? _lastConfigJson;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor
index 5eec1f55..8911b438 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor
@@ -32,13 +32,19 @@
CurrentAddress="@_pickerAddress"
OnPickAddress="@OnAddressPicked">
+ CurrentAddressChanged="@((s) => _pickerAddress = s)"
+ DriverType="@DriverType"
+ GetConfigJson="@GetDriverConfigJson" />
}
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// DriverType of the selected driver — lets browse-capable pickers open a universal session.
+ [Parameter] public string DriverType { get; set; } = "";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}";
private FocasTagConfigModel _m = new();
private string? _lastConfigJson;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor
index b9449866..116f9bd5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor
@@ -49,6 +49,10 @@
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// DriverType of the selected driver — lets browse-capable pickers open a universal session.
+ [Parameter] public string DriverType { get; set; } = "";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}";
private ModbusTagConfigModel _m = new();
private string? _lastConfigJson;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor
index 58ea6ade..ef4c9d34 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/OpcUaClientTagConfigEditor.razor
@@ -11,6 +11,10 @@
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// DriverType of the selected driver — lets browse-capable pickers open a universal session.
+ [Parameter] public string DriverType { get; set; } = "";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}";
private OpcUaClientTagConfigModel _m = new();
private string? _lastConfigJson;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor
index aba5047a..7d95c364 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor
@@ -41,6 +41,10 @@
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// DriverType of the selected driver — lets browse-capable pickers open a universal session.
+ [Parameter] public string DriverType { get; set; } = "";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}";
private S7TagConfigModel _m = new();
private string? _lastConfigJson;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor
index 143fdee0..1334e55f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor
@@ -34,13 +34,19 @@
CurrentAddress="@_pickerAddress"
OnPickAddress="@OnAddressPicked">
+ CurrentAddressChanged="@((s) => _pickerAddress = s)"
+ DriverType="@DriverType"
+ GetConfigJson="@GetDriverConfigJson" />
}
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// DriverType of the selected driver — lets browse-capable pickers open a universal session.
+ [Parameter] public string DriverType { get; set; } = "";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}";
private TwinCATTagConfigModel _m = new();
private string? _lastConfigJson;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor
index 677656b9..a91a79be 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor
@@ -353,6 +353,8 @@
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v),
+ ["DriverType"] = SelectedDriverType ?? "",
+ ["GetDriverConfigJson"] = (Func)(() => SelectedDriverConfig),
};
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
index d8a1eb98..5f2578c6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs
@@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
@@ -48,6 +50,12 @@ public static class EndpointRouteBuilderExtensions
services.AddScoped();
services.AddSingleton();
services.AddSingleton();
+ // Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
+ // fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
+ // → CanBrowse false → pickers gracefully stay manual-entry (design §6).
+ services.AddSingleton(sp => new DiscoveryDriverBrowser(
+ sp.GetService() ?? NullDriverFactory.Instance,
+ sp.GetRequiredService>()));
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
services.AddScoped();
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs
new file mode 100644
index 00000000..9aa3a433
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs
@@ -0,0 +1,115 @@
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+public class CapturedTreeBrowseSessionTests
+{
+ private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) =>
+ new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm);
+
+ // A small captured graph: a folder Plc1 with a Speed leaf and an alarm leaf, plus a root-level leaf.
+ // truncated=true builds with a tiny cap so recording stops short and Truncated flips.
+ private static CapturedTree SampleTree(bool truncated = false)
+ {
+ var b = new CapturingAddressSpaceBuilder(nodeCap: truncated ? 2 : 100);
+ var plc = b.Folder("Plc1", "PLC 1");
+ plc.Variable("Speed", "Speed", Attr("Plc1.Speed"));
+ plc.Variable("Alarm", "Alarm", Attr("Plc1.Alarm")).MarkAsAlarmCondition(
+ new AlarmConditionInfo("Alarm", AlarmSeverity.High, null));
+ b.Variable("Direct", "Direct", Attr("Root.Direct"));
+ return b.Build();
+ }
+
+ [Fact]
+ public async Task RootReturnsTopLevelNodes()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ var root = await s.RootAsync(CancellationToken.None);
+
+ var folder = root.Single(n => n.NodeId == "/Plc1");
+ folder.Kind.ShouldBe(BrowseNodeKind.Folder);
+ folder.HasChildrenHint.ShouldBeTrue();
+
+ var leaf = root.Single(n => n.NodeId == "Root.Direct");
+ leaf.Kind.ShouldBe(BrowseNodeKind.Leaf);
+ leaf.NodeId.ShouldBe("Root.Direct"); // leaf NodeId IS the driver FullName
+ }
+
+ [Fact]
+ public async Task ExpandReturnsCapturedChildren()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ var kids = await s.ExpandAsync("/Plc1", CancellationToken.None);
+ kids.Select(n => n.NodeId).ShouldBe(new[] { "Plc1.Speed", "Plc1.Alarm" });
+ }
+
+ [Fact]
+ public async Task ExpandUnknownNodeReturnsEmpty()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ (await s.ExpandAsync("/nope", CancellationToken.None)).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task AttributesForLeafMapsDriverAttributeInfo()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ var attrs = await s.AttributesAsync("Plc1.Speed", CancellationToken.None);
+ var a = attrs.Single();
+ a.Name.ShouldBe("Speed");
+ a.DriverDataType.ShouldBe(DriverDataType.Float64.ToString());
+ a.IsArray.ShouldBeFalse();
+ a.SecurityClass.ShouldBe(SecurityClassification.Operate.ToString());
+ a.IsAlarm.ShouldBeFalse();
+
+ // Alarm marked during discovery surfaces IsAlarm=true even though attr.IsAlarm is false.
+ var alarmAttrs = await s.AttributesAsync("Plc1.Alarm", CancellationToken.None);
+ alarmAttrs.Single().IsAlarm.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task TruncatedTreeAppendsMarkerFolderAtRoot()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree(truncated: true));
+ var root = await s.RootAsync(CancellationToken.None);
+ var marker = root[^1];
+ marker.Kind.ShouldBe(BrowseNodeKind.Folder);
+ marker.HasChildrenHint.ShouldBeFalse();
+ marker.DisplayName.ToLowerInvariant().ShouldContain("truncated");
+ }
+
+ [Fact]
+ public async Task CallsRefreshLastUsedUtc()
+ {
+ await using var s = new CapturedTreeBrowseSession(SampleTree());
+ var before = s.LastUsedUtc;
+ await Task.Delay(5, TestContext.Current.CancellationToken);
+ await s.RootAsync(CancellationToken.None);
+ s.LastUsedUtc.ShouldBeGreaterThan(before);
+ }
+
+ [Fact]
+ public async Task DisposedSessionThrowsObjectDisposed()
+ {
+ var s = new CapturedTreeBrowseSession(SampleTree());
+ await s.DisposeAsync();
+ await Should.ThrowAsync(() => s.RootAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task TwoSessionsOverSameTreeAreIndependent()
+ {
+ var tree = SampleTree();
+ var s1 = new CapturedTreeBrowseSession(tree);
+ var s2 = new CapturedTreeBrowseSession(tree);
+ s1.Token.ShouldNotBe(s2.Token);
+
+ await s1.DisposeAsync();
+ // Disposing s1 must not affect s2 — the tree is immutable and shared.
+ (await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty();
+ await s2.DisposeAsync();
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs
new file mode 100644
index 00000000..7c7018e9
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturingAddressSpaceBuilderTests.cs
@@ -0,0 +1,70 @@
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+public class CapturingAddressSpaceBuilderTests
+{
+ private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) =>
+ new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm);
+
+ [Fact]
+ public void CapturesNestedFoldersAndLeaves()
+ {
+ var b = new CapturingAddressSpaceBuilder(nodeCap: 100);
+ var plc = b.Folder("Plc1", "PLC 1");
+ plc.Variable("Speed", "Speed", Attr("Program:Main.Speed"));
+ var udt = plc.Folder("Motor", "Motor");
+ udt.Variable("Amps", "Amps", Attr("Program:Main.Motor.Amps"));
+
+ var tree = b.Build();
+ tree.Truncated.ShouldBeFalse();
+ tree.Root.Children.Count.ShouldBe(1);
+ var plcNode = tree.Root.Children[0];
+ plcNode.Id.ShouldBe("/Plc1");
+ plcNode.IsLeaf.ShouldBeFalse();
+ plcNode.Children.Select(c => c.Id).ShouldBe(new[] { "Program:Main.Speed", "/Plc1/Motor" });
+ tree.ById["Program:Main.Motor.Amps"].Attribute!.FullName.ShouldBe("Program:Main.Motor.Amps");
+ }
+
+ [Fact]
+ public void LeafNodeIdIsTheDriverFullName()
+ {
+ var b = new CapturingAddressSpaceBuilder(100);
+ var handle = b.Variable("T", "T", Attr("Device.Tag1"));
+ handle.FullReference.ShouldBe("Device.Tag1");
+ b.Build().ById["Device.Tag1"].IsLeaf.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void NodeCapTruncatesInsteadOfGrowing()
+ {
+ var b = new CapturingAddressSpaceBuilder(nodeCap: 3);
+ for (var i = 0; i < 10; i++) b.Variable($"T{i}", $"T{i}", Attr($"Dev.T{i}"));
+ var tree = b.Build();
+ tree.Truncated.ShouldBeTrue();
+ tree.Count.ShouldBe(3);
+ }
+
+ [Fact]
+ public void MarkAsAlarmConditionRecordsAlarmAndReturnsNoopSink()
+ {
+ var b = new CapturingAddressSpaceBuilder(100);
+ var h = b.Variable("A", "A", Attr("Dev.Alarm1"));
+ var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("A", AlarmSeverity.High, null));
+ sink.ShouldNotBeNull();
+ Should.NotThrow(() => sink.OnTransition(null!)); // no-op — browse never delivers transitions
+ b.Build().ById["Dev.Alarm1"].IsAlarmMarked.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void AddPropertyAttachesToCurrentScope()
+ {
+ var b = new CapturingAddressSpaceBuilder(100);
+ var f = b.Folder("Dev", "Dev");
+ f.AddProperty("Model", DriverDataType.String, "1756-L83E");
+ b.Build().ById["/Dev"].Properties.ShouldContain(p => p.Name == "Model" && (string?)p.Value == "1756-L83E");
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs
new file mode 100644
index 00000000..610585a6
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserConcurrencyTests.cs
@@ -0,0 +1,177 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+public class DiscoveryDriverBrowserConcurrencyTests
+{
+ private static readonly TimeSpan LongOpen = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200);
+ private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5);
+
+ private static DiscoveryDriverBrowser Browser(IDriverFactory factory) =>
+ new(factory, NullLogger.Instance, LongOpen, ShortShutdown);
+
+ /// A driver gate: signals when Initialize is entered, blocks until released.
+ private sealed class Gate
+ {
+ public readonly SemaphoreSlim Entered = new(0);
+ public readonly TaskCompletionSource Release = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ }
+
+ private static FakeDiscoveryDriver Gated(Gate gate) => new()
+ {
+ OnInitialize = async ct =>
+ {
+ gate.Entered.Release();
+ await gate.Release.Task.WaitAsync(ct);
+ },
+ };
+
+ [Fact]
+ public async Task SameKey_TwoConcurrentOpens_OneCapture_DistinctTokens()
+ {
+ var gate = new Gate();
+ var factory = FakeDriverFactory.Of(() => Gated(gate));
+ var browser = Browser(factory);
+
+ var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
+ var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
+
+ await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken); // one driver entered Initialize
+ factory.CreateCalls.ShouldBe(1); // coalesced — only one driver constructed
+ gate.Release.SetResult();
+
+ var s1 = await open1;
+ var s2 = await open2;
+ s1.Token.ShouldNotBe(s2.Token); // distinct sessions...
+ (await s1.RootAsync(CancellationToken.None)).Count
+ .ShouldBe((await s2.RootAsync(CancellationToken.None)).Count); // ...over the same tree
+ ((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task SameKey_DisposingOneSession_LeavesOtherBrowsable()
+ {
+ var gate = new Gate();
+ var browser = Browser(FakeDriverFactory.Of(() => Gated(gate)));
+
+ var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
+ var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
+ await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken);
+ gate.Release.SetResult();
+
+ var s1 = await open1;
+ var s2 = await open2;
+ await s1.DisposeAsync();
+ (await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty(); // shared immutable tree survives
+ }
+
+ [Fact]
+ public async Task SameKey_FailedCapture_FailsBothAwaiters()
+ {
+ var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver
+ {
+ OnDiscover = (_, _) => throw new InvalidOperationException("shared-boom"),
+ });
+ var browser = Browser(factory);
+
+ var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
+ var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
+
+ (await Should.ThrowAsync(() => open1)).Message.ShouldBe("shared-boom");
+ (await Should.ThrowAsync(() => open2)).Message.ShouldBe("shared-boom");
+ }
+
+ [Fact]
+ public async Task SameKey_OpenAfterCompletion_RunsFreshCapture()
+ {
+ var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver()); // ungated, completes fast
+ var browser = Browser(factory);
+
+ await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
+ await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
+
+ factory.CreateCalls.ShouldBe(2); // in-flight-only map: second open is a fresh capture (Refresh path)
+ }
+
+ [Fact]
+ public async Task DistinctKeys_CapLimitsInflightCaptures()
+ {
+ const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4
+ const int total = cap + 2; // 6
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var entered = 0;
+ var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver
+ {
+ OnInitialize = async ct => { Interlocked.Increment(ref entered); await release.Task.WaitAsync(ct); },
+ });
+ var browser = Browser(factory);
+
+ var opens = Enumerable.Range(0, total)
+ .Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None))
+ .ToArray();
+
+ // Give the semaphore time to admit exactly `cap` captures into Initialize.
+ await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken);
+ Volatile.Read(ref entered).ShouldBe(cap); // at most 4 in flight; the other 2 queue on the semaphore
+
+ release.SetResult();
+ var sessions = await Task.WhenAll(opens);
+ sessions.Length.ShouldBe(total);
+ factory.CreateCalls.ShouldBe(total); // all eventually run once a slot frees
+ }
+
+ [Fact]
+ public async Task QueuedOpen_ObservesCallerCancellation()
+ {
+ const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver
+ {
+ OnInitialize = async ct => { await release.Task.WaitAsync(ct); },
+ });
+ var browser = Browser(factory);
+
+ // Fill all slots with distinct-key captures.
+ var inflight = Enumerable.Range(0, cap)
+ .Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None))
+ .ToArray();
+ await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
+
+ // A queued open with a cancelable token — it waits for a slot, then the caller cancels.
+ using var cts = new CancellationTokenSource();
+ var queued = browser.OpenAsync("Fake", """{"i":"queued"}""", cts.Token);
+ cts.Cancel();
+ await Should.ThrowAsync(() => queued);
+
+ // The in-flight captures are unaffected.
+ release.SetResult();
+ var done = await Task.WhenAll(inflight);
+ done.Length.ShouldBe(cap);
+ }
+
+ [Fact]
+ public async Task CoalescedAwaiter_CancellationDoesNotCancelSharedCapture()
+ {
+ var gate = new Gate();
+ var factory = FakeDriverFactory.Of(() => Gated(gate));
+ var browser = Browser(factory);
+
+ using var cts1 = new CancellationTokenSource();
+ var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", cts1.Token);
+ var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
+
+ await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken);
+ cts1.Cancel(); // first caller abandons
+ await Should.ThrowAsync(() => open1);
+
+ gate.Release.SetResult(); // shared capture proceeds regardless
+ var s2 = await open2;
+ (await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty();
+ ((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1);
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs
new file mode 100644
index 00000000..78f45228
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserFakes.cs
@@ -0,0 +1,118 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+/// Shared test doubles for the DiscoveryDriverBrowser suites (Tasks 8–10).
+internal sealed class FakeDiscoveryDriver : IDriver, ITagDiscovery
+{
+ public int InitializeCalls;
+ public int DiscoverCalls;
+ public int ShutdownCalls;
+
+ /// Ordered lifecycle trace ("init"/"discover"/"shutdown").
+ public List Trace { get; } = [];
+
+ /// Completes once ShutdownAsync has finished (even the fire-and-forget CanBrowse path).
+ public TaskCompletionSource ShutdownDone { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public bool SupportsOnlineDiscovery { get; init; } = true;
+ public DiscoveryRediscoverPolicy RediscoverPolicy { get; init; } = DiscoveryRediscoverPolicy.Once;
+
+ /// Discovery behavior: (builder, 1-based call index) → task. Default streams one leaf.
+ public Func? OnDiscover { get; init; }
+ public Func? OnInitialize { get; init; }
+ public Func? OnShutdown { get; init; }
+
+ public string DriverInstanceId { get; }
+ public string DriverType { get; }
+ public string? LastConfigJson { get; private set; }
+
+ public FakeDiscoveryDriver(string driverType = "Fake", string id = "fake-1")
+ {
+ DriverType = driverType;
+ DriverInstanceId = id;
+ }
+
+ public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ LastConfigJson = driverConfigJson;
+ Interlocked.Increment(ref InitializeCalls);
+ lock (Trace) Trace.Add("init");
+ if (OnInitialize is not null) await OnInitialize(cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
+ {
+ var idx = Interlocked.Increment(ref DiscoverCalls);
+ lock (Trace) Trace.Add("discover");
+ if (OnDiscover is not null)
+ {
+ await OnDiscover(builder, idx).ConfigureAwait(false);
+ return;
+ }
+ // Default: stream one leaf so the captured tree is non-empty.
+ builder.Variable("Tag1", "Tag1",
+ new DriverAttributeInfo("Dev.Tag1", DriverDataType.Float64, false, null,
+ SecurityClassification.Operate, false));
+ }
+
+ public async Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ Interlocked.Increment(ref ShutdownCalls);
+ lock (Trace) Trace.Add("shutdown");
+ try
+ {
+ if (OnShutdown is not null) await OnShutdown(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ ShutdownDone.TrySetResult();
+ }
+ }
+
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
+ public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
+ public long GetMemoryFootprint() => 0;
+ public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
+
+/// An IDriver that is NOT ITagDiscovery — exercises the not-discovery-capable gate.
+internal sealed class FakeNonDiscoveryDriver(string driverType = "Flat", string id = "flat-1") : IDriver
+{
+ public int ShutdownCalls;
+ public TaskCompletionSource ShutdownDone { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public string DriverInstanceId { get; } = id;
+ public string DriverType { get; } = driverType;
+ public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ Interlocked.Increment(ref ShutdownCalls);
+ ShutdownDone.TrySetResult();
+ return Task.CompletedTask;
+ }
+ public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
+ public long GetMemoryFootprint() => 0;
+ public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
+
+/// Configurable IDriverFactory: delegates construction to a supplied func and records what it made.
+internal sealed class FakeDriverFactory(Func create) : IDriverFactory
+{
+ public int CreateCalls;
+ public List Created { get; } = [];
+
+ public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
+ {
+ Interlocked.Increment(ref CreateCalls);
+ var d = create(driverType, driverInstanceId, driverConfigJson);
+ if (d is not null) lock (Created) Created.Add(d);
+ return d;
+ }
+
+ public IReadOnlyCollection SupportedTypes { get; } = Array.Empty();
+
+ /// Factory that always returns a fresh default FakeDiscoveryDriver.
+ public static FakeDriverFactory Of(Func make) =>
+ new((_, _, _) => make());
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs
new file mode 100644
index 00000000..2a59f80d
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserSettleTests.cs
@@ -0,0 +1,87 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+public class DiscoveryDriverBrowserSettleTests
+{
+ private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200);
+
+ private static DiscoveryDriverBrowser Browser(IDriverFactory factory, TimeSpan openTimeout) =>
+ new(factory, NullLogger.Instance, openTimeout, ShortShutdown);
+
+ private static void StreamN(IAddressSpaceBuilder builder, int n)
+ {
+ for (var i = 0; i < n; i++)
+ builder.Variable($"T{i}", $"T{i}",
+ new DriverAttributeInfo($"Dev.T{i}", DriverDataType.Float64, false, null,
+ SecurityClassification.Operate, false));
+ }
+
+ [Fact]
+ public async Task UntilStable_RerunsUntilNonEmptyAndStable()
+ {
+ // Pass 1 streams nothing (FixedTree cache still filling); passes 2+ stream 3 nodes.
+ var driver = new FakeDiscoveryDriver
+ {
+ RediscoverPolicy = DiscoveryRediscoverPolicy.UntilStable,
+ OnDiscover = (b, idx) => { if (idx >= 2) StreamN(b, 3); return Task.CompletedTask; },
+ };
+ var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(30));
+
+ var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
+ var root = await session.RootAsync(CancellationToken.None);
+
+ root.Count.ShouldBe(3);
+ driver.DiscoverCalls.ShouldBeGreaterThanOrEqualTo(3); // empty -> 3 -> 3-stable
+ }
+
+ [Fact]
+ public async Task UntilStable_NeverStable_ReturnsLastNonEmptyAtTimeout()
+ {
+ // Node count grows every pass — never stabilises; on open-timeout return the last capture.
+ var driver = new FakeDiscoveryDriver
+ {
+ RediscoverPolicy = DiscoveryRediscoverPolicy.UntilStable,
+ OnDiscover = (b, idx) => { StreamN(b, idx); return Task.CompletedTask; },
+ };
+ var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(3));
+
+ var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
+ var root = await session.RootAsync(CancellationToken.None);
+
+ root.ShouldNotBeEmpty(); // last non-empty capture, not an exception
+ }
+
+ [Fact]
+ public async Task UntilStable_NeverAnyNodes_FailsAtOpenTimeout()
+ {
+ // Every pass empty — no non-empty capture to fall back to, so the timeout surfaces.
+ var driver = new FakeDiscoveryDriver
+ {
+ RediscoverPolicy = DiscoveryRediscoverPolicy.UntilStable,
+ OnDiscover = (_, _) => Task.CompletedTask,
+ };
+ var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(2));
+
+ await Should.ThrowAsync(
+ () => browser.OpenAsync("Fake", "{}", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task OncePolicy_SingleDiscoverPass()
+ {
+ var driver = new FakeDiscoveryDriver
+ {
+ RediscoverPolicy = DiscoveryRediscoverPolicy.Once,
+ OnDiscover = (b, _) => { StreamN(b, 3); return Task.CompletedTask; },
+ };
+ var browser = Browser(FakeDriverFactory.Of(() => driver), TimeSpan.FromSeconds(30));
+
+ await browser.OpenAsync("Fake", "{}", CancellationToken.None);
+ driver.DiscoverCalls.ShouldBe(1);
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs
new file mode 100644
index 00000000..14dec972
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserTests.cs
@@ -0,0 +1,145 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+public class DiscoveryDriverBrowserTests
+{
+ private static readonly TimeSpan ShortOpen = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200);
+
+ private static DiscoveryDriverBrowser Browser(IDriverFactory factory) =>
+ new(factory, NullLogger.Instance, ShortOpen, ShortShutdown);
+
+ [Fact]
+ public async Task OpenAsync_RunsInitializeDiscoverShutdown_InOrder()
+ {
+ var driver = new FakeDiscoveryDriver();
+ var browser = Browser(FakeDriverFactory.Of(() => driver));
+
+ var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
+
+ session.ShouldNotBeNull();
+ driver.Trace.ShouldBe(new[] { "init", "discover", "shutdown" });
+ driver.InitializeCalls.ShouldBe(1);
+ driver.ShutdownCalls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task OpenAsync_ShutdownRuns_EvenWhenDiscoverThrows()
+ {
+ var driver = new FakeDiscoveryDriver
+ {
+ OnDiscover = (_, _) => throw new InvalidOperationException("boom-discovery"),
+ };
+ var browser = Browser(FakeDriverFactory.Of(() => driver));
+
+ var ex = await Should.ThrowAsync(
+ () => browser.OpenAsync("Fake", "{}", CancellationToken.None));
+ ex.Message.ShouldBe("boom-discovery"); // discovery error surfaces, not a shutdown error
+ driver.ShutdownCalls.ShouldBe(1); // shutdown still ran
+ }
+
+ [Fact]
+ public async Task OpenAsync_UnknownType_ThrowsCleanMessage()
+ {
+ var browser = Browser(new FakeDriverFactory((_, _, _) => null));
+ var ex = await Should.ThrowAsync(
+ () => browser.OpenAsync("Ghost", "{}", CancellationToken.None));
+ ex.Message.ShouldContain("Ghost");
+ }
+
+ [Fact]
+ public async Task OpenAsync_NotDiscoveryCapable_Throws()
+ {
+ var browser = Browser(new FakeDriverFactory((_, _, _) => new FakeNonDiscoveryDriver()));
+ await Should.ThrowAsync(
+ () => browser.OpenAsync("Flat", "{}", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task OpenAsync_FlagFalse_Throws()
+ {
+ var browser = Browser(FakeDriverFactory.Of(() => new FakeDiscoveryDriver { SupportsOnlineDiscovery = false }));
+ await Should.ThrowAsync(
+ () => browser.OpenAsync("Fake", "{}", CancellationToken.None));
+ }
+
+ [Fact]
+ public void PatchForBrowse_MergesAbCipPatch()
+ {
+ var patched = DiscoveryDriverBrowser.PatchForBrowse("AbCip", """{ "Host": "1.2.3.4" }""");
+ var obj = System.Text.Json.Nodes.JsonNode.Parse(patched)!.AsObject();
+ ((string?)obj["Host"]).ShouldBe("1.2.3.4");
+ ((bool?)obj["EnableControllerBrowse"]).ShouldBe(true);
+
+ // Unknown type: unchanged.
+ DiscoveryDriverBrowser.PatchForBrowse("Modbus", """{ "X": 1 }""").ShouldBe("""{ "X": 1 }""");
+ }
+
+ [Fact]
+ public void PatchForBrowse_DeepMergesFocasFixedTree()
+ {
+ var patched = DiscoveryDriverBrowser.PatchForBrowse("FOCAS", """{ "FixedTree": { "Series": "30i" } }""");
+ var fixedTree = System.Text.Json.Nodes.JsonNode.Parse(patched)!["FixedTree"]!.AsObject();
+ ((string?)fixedTree["Series"]).ShouldBe("30i"); // kept
+ ((bool?)fixedTree["Enabled"]).ShouldBe(true); // added
+ }
+
+ [Fact]
+ public void CanBrowse_TrueForDiscoveryCapable()
+ {
+ var browser = Browser(FakeDriverFactory.Of(() => new FakeDiscoveryDriver()));
+ browser.CanBrowse("Fake", "{}").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void CanBrowse_FalseWhenTryCreateReturnsNull()
+ {
+ var browser = Browser(new FakeDriverFactory((_, _, _) => null));
+ browser.CanBrowse("Ghost", "{}").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void CanBrowse_FalseWhenTryCreateThrows_NoExceptionEscapes()
+ {
+ var browser = Browser(new FakeDriverFactory((_, _, _) => throw new System.Text.Json.JsonException("bad json")));
+ Should.NotThrow(() => browser.CanBrowse("Fake", "{ half-typed").ShouldBeFalse());
+ }
+
+ [Fact]
+ public async Task CanBrowse_ShutsDownThrowawayInstance()
+ {
+ var driver = new FakeDiscoveryDriver { SupportsOnlineDiscovery = false };
+ var factory = FakeDriverFactory.Of(() => driver);
+ var browser = Browser(factory);
+
+ browser.CanBrowse("Fake", "{}").ShouldBeFalse();
+ // Teardown is fire-and-forget — await its completion.
+ await driver.ShutdownDone.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
+ driver.ShutdownCalls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task BoundedShutdown_AbandonsWedgedDriver()
+ {
+ // Shutdown ignores its token and would never complete; the browser must abandon it
+ // and still return the successfully-captured session within the shutdown bound.
+ var driver = new FakeDiscoveryDriver
+ {
+ OnShutdown = _ => Task.Delay(Timeout.Infinite, CancellationToken.None),
+ };
+ var browser = Browser(FakeDriverFactory.Of(() => driver));
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
+ sw.Stop();
+
+ session.ShouldNotBeNull(); // discovery succeeded → usable session
+ driver.ShutdownCalls.ShouldBe(1); // shutdown was attempted
+ sw.Elapsed.ShouldBeLessThan(ShortOpen); // returned on the shutdown bound, not the open timeout
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs
new file mode 100644
index 00000000..dbe114b7
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagDiscoveryDefaultsTests.cs
@@ -0,0 +1,21 @@
+using Shouldly;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
+
+public class TagDiscoveryDefaultsTests
+{
+ private sealed class MinimalDiscovery : ITagDiscovery
+ {
+ public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask;
+ }
+
+ [Fact]
+ public void SupportsOnlineDiscovery_DefaultsToFalse() =>
+ ((ITagDiscovery)new MinimalDiscovery()).SupportsOnlineDiscovery.ShouldBeFalse();
+
+ [Fact]
+ public void RediscoverPolicy_DefaultRemainsUntilStable() =>
+ ((ITagDiscovery)new MinimalDiscovery()).RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDiscoveryGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDiscoveryGateTests.cs
new file mode 100644
index 00000000..e832f236
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDiscoveryGateTests.cs
@@ -0,0 +1,19 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class AbCipDiscoveryGateTests
+{
+ /// AbCip opts into the universal discovery browser — its CIP Symbol Object walk is real
+ /// device enumeration (config-gated on EnableControllerBrowse, guaranteed by PatchForBrowse at open).
+ [Fact]
+ public void SupportsOnlineDiscovery_IsTrue()
+ {
+ var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
+ ((ITagDiscovery)drv).SupportsOnlineDiscovery.ShouldBeTrue();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiscoveryGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiscoveryGateTests.cs
new file mode 100644
index 00000000..a920d596
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDiscoveryGateTests.cs
@@ -0,0 +1,20 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class FocasDiscoveryGateTests
+{
+ /// FOCAS opts into the universal discovery browser — its FixedTree device folder is real
+ /// enumeration (config-gated on FixedTree.Enabled, turned on by PatchForBrowse). FOCAS is the
+ /// UntilStable driver: the browser's settle loop re-captures until the FixedTree cache fills.
+ [Fact]
+ public void SupportsOnlineDiscovery_IsTrue()
+ {
+ var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
+ ((ITagDiscovery)drv).SupportsOnlineDiscovery.ShouldBeTrue();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiscoveryGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiscoveryGateTests.cs
new file mode 100644
index 00000000..97da1f2c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATDiscoveryGateTests.cs
@@ -0,0 +1,19 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class TwinCATDiscoveryGateTests
+{
+ /// TwinCAT opts into the universal discovery browser — its ADS symbol upload is real
+ /// device enumeration (config-gated on EnableControllerBrowse, guaranteed by PatchForBrowse at open).
+ [Fact]
+ public void SupportsOnlineDiscovery_IsTrue()
+ {
+ var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1");
+ ((ITagDiscovery)drv).SupportsOnlineDiscovery.ShouldBeTrue();
+ }
+}
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
index d69d8322..1939dfa4 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs
@@ -13,7 +13,11 @@ public sealed class BrowserSessionServiceTests
{
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
- new(browsers, registry, NullLogger.Instance);
+ new(browsers, registry, NullLogger.Instance, new FakeUniversalDriverBrowser());
+
+ private static BrowserSessionService NewService(
+ BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
+ new(browsers, registry, NullLogger.Instance, universal);
[Fact]
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
@@ -68,6 +72,89 @@ public sealed class BrowserSessionServiceTests
result.Message!.ShouldContain("boom");
}
+ [Fact]
+ public async Task Open_BespokeBrowserWinsOverUniversal()
+ {
+ var registry = new BrowseSessionRegistry();
+ var bespokeSession = new FakeBrowseSession();
+ var bespoke = new FakeDriverBrowser("AbCip")
+ {
+ OpenHandler = (_, _) => Task.FromResult(bespokeSession),
+ };
+ var universal = new FakeUniversalDriverBrowser
+ {
+ CanBrowsePredicate = (_, _) => true, // universal would also serve it
+ OpenHandler = (_, _, _) => Task.FromResult(new FakeBrowseSession()),
+ };
+ var service = NewService(registry, universal, bespoke);
+
+ var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
+
+ result.Ok.ShouldBeTrue();
+ result.Token.ShouldBe(bespokeSession.Token); // bespoke session, not the universal one
+ }
+
+ [Fact]
+ public async Task Open_FallsBackToUniversal_WhenNoBespokeAndCanBrowse()
+ {
+ var registry = new BrowseSessionRegistry();
+ var universalSession = new FakeBrowseSession();
+ var universal = new FakeUniversalDriverBrowser
+ {
+ CanBrowsePredicate = (t, _) => t == "AbCip",
+ OpenHandler = (_, _, _) => Task.FromResult(universalSession),
+ };
+ var service = NewService(registry, universal); // no bespoke browsers
+
+ var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
+
+ result.Ok.ShouldBeTrue();
+ result.Token.ShouldBe(universalSession.Token);
+ registry.TryGet(result.Token, out _).ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage()
+ {
+ var registry = new BrowseSessionRegistry();
+ var service = NewService(registry, new FakeUniversalDriverBrowser()); // CanBrowse=false
+
+ var result = await service.OpenAsync("Modbus", "{}", CancellationToken.None);
+
+ result.Ok.ShouldBeFalse();
+ result.Token.ShouldBe(Guid.Empty);
+ result.Message!.ShouldContain("Modbus");
+ }
+
+ [Fact]
+ public async Task Open_UniversalThrow_ReturnsOkFalseWithMessage()
+ {
+ var registry = new BrowseSessionRegistry();
+ var universal = new FakeUniversalDriverBrowser
+ {
+ CanBrowsePredicate = (_, _) => true,
+ OpenHandler = (_, _, _) => throw new InvalidOperationException("universal-boom"),
+ };
+ var service = NewService(registry, universal);
+
+ var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
+
+ result.Ok.ShouldBeFalse();
+ result.Message!.ShouldContain("universal-boom");
+ }
+
+ [Fact]
+ public void CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise()
+ {
+ var registry = new BrowseSessionRegistry();
+ var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (t, _) => t == "AbCip" };
+ var service = NewService(registry, universal, new FakeDriverBrowser("Galaxy"));
+
+ service.CanBrowse("Galaxy", "{}").ShouldBeTrue(); // bespoke registered
+ service.CanBrowse("AbCip", "{}").ShouldBeTrue(); // universal can serve
+ service.CanBrowse("Modbus", "{}").ShouldBeFalse(); // neither
+ }
+
[Fact]
public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeUniversalDriverBrowser.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeUniversalDriverBrowser.cs
new file mode 100644
index 00000000..9edd82f1
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/FakeUniversalDriverBrowser.cs
@@ -0,0 +1,20 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
+
+/// Test double for . CanBrowse is driven by a
+/// predicate (default: never); OpenAsync delegates to a handler or returns a fresh session.
+internal sealed class FakeUniversalDriverBrowser : IUniversalDriverBrowser
+{
+ /// CanBrowse gate. Defaults to "no universal affordance for any type".
+ public Func CanBrowsePredicate { get; init; } = (_, _) => false;
+
+ /// OpenAsync behavior. Defaults to a fresh FakeBrowseSession.
+ public Func> OpenHandler { get; init; } =
+ (_, _, _) => Task.FromResult(new FakeBrowseSession());
+
+ public bool CanBrowse(string driverType, string configJson) => CanBrowsePredicate(driverType, configJson);
+
+ public Task OpenAsync(string driverType, string configJson, CancellationToken ct) =>
+ OpenHandler(driverType, configJson, ct);
+}