diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs
new file mode 100644
index 00000000..d9e560e3
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/Fakes/FakeReconnectDriverFactory.cs
@@ -0,0 +1,103 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// Opt-in fake the can register
+/// so a deployed driver actually reaches Connected (Healthy) in-process — no Docker sim and
+/// no real backend. Materialises a for the "Modbus" driver
+/// type (any other type returns null, mirroring ).
+///
+///
+/// The DI wiring resolves from the container; the production
+/// AddOtOpcUaRuntime() seeds via TryAddSingleton, so a
+/// harness that wants live drivers registers this fake first (its registration wins). Every
+/// initialize/connect succeeds, so the wrapping DriverInstanceActor walks
+/// Connecting → Connected → Healthy and (on ReconnectDriver) drives
+/// ForceReconnect → Reconnecting → re-initialize → Connected without any fault injection.
+///
+public sealed class FakeReconnectDriverFactory : IDriverFactory
+{
+ /// The single driver type this fake factory materialises.
+ public const string FakeDriverType = "Modbus";
+
+ ///
+ /// Returns a when is
+ /// ; otherwise null (the host logs + skips the row).
+ ///
+ /// The driver type name from the deployed DriverInstance row.
+ /// The stable driver-instance identifier.
+ /// The driver configuration as a JSON string (ignored by the fake).
+ /// A new , or null for an unsupported type.
+ public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
+ => driverType == FakeDriverType
+ ? new FakeReconnectDriver(driverInstanceId, driverType)
+ : null;
+
+ /// Gets the driver-type names this factory can materialise.
+ public IReadOnlyCollection SupportedTypes { get; } = new[] { FakeDriverType };
+}
+
+///
+/// A minimal in-process whose connect/initialize path always succeeds, so the
+/// wrapping DriverInstanceActor reaches Connected and publishes a
+/// snapshot. Reads/writes/subscribes are benign no-ops; this exists
+/// only to let a deployed driver walk the Healthy ↔ Reconnecting FSM in-process for E2E tests.
+///
+public sealed class FakeReconnectDriver : IDriver
+{
+ ///
+ /// Initializes a new .
+ ///
+ /// The stable logical driver-instance identifier.
+ /// The driver type name.
+ public FakeReconnectDriver(string driverInstanceId, string driverType)
+ {
+ DriverInstanceId = driverInstanceId;
+ DriverType = driverType;
+ }
+
+ /// Gets the stable logical ID of this driver instance.
+ public string DriverInstanceId { get; }
+
+ /// Gets the driver type name (e.g. "Modbus").
+ public string DriverType { get; }
+
+ ///
+ /// Connect/initialize path — always succeeds (returns a completed task), so the actor self-Tells
+ /// InitializeSucceeded and becomes Connected. This is the method that makes connect
+ /// succeed; the FSM's reconnect path re-invokes it and it succeeds again.
+ ///
+ /// The driver configuration JSON (ignored).
+ /// Cancellation token for the operation.
+ /// A completed task — initialization always succeeds.
+ public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ /// Applies a config change in place — a no-op that always succeeds.
+ /// The driver configuration JSON (ignored).
+ /// Cancellation token for the operation.
+ /// A completed task — reinitialization always succeeds.
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ /// Stops the driver — a no-op that always succeeds.
+ /// Cancellation token for the operation.
+ /// A completed task.
+ public Task ShutdownAsync(CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ /// Returns a snapshot so deployed drivers publish health.
+ /// A in the state.
+ public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
+
+ /// Returns a zero memory footprint (the fake holds no driver-attributable caches).
+ /// Always 0.
+ public long GetMemoryFootprint() => 0;
+
+ /// Flushes optional caches — a no-op (the fake holds none).
+ /// Cancellation token for the operation.
+ /// A completed task.
+ public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
+ => Task.CompletedTask;
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
index 09093f4e..a80f36a5 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs
@@ -15,6 +15,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Security;
@@ -71,6 +72,12 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
private string? _sqlDbName;
private string? _sqlConnString;
+ /// Optional opt-in both nodes register so deployed drivers can
+ /// reach Connected/Healthy in-process. Null (default) leaves the production
+ /// from AddOtOpcUaRuntime() in place — unchanged behaviour for
+ /// existing tests.
+ private IDriverFactory? _driverFactory;
+
/// Gets the first web application node.
public WebApplication NodeA { get; private set; } = null!;
/// Gets the second web application node.
@@ -109,9 +116,15 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
/// Boots both nodes and waits up to for cluster convergence.
/// Maximum time to wait for cluster formation; defaults to 20 seconds if not provided.
- public static async Task StartAsync(TimeSpan? formationTimeout = null)
+ /// Optional opt-in both nodes register so deployed
+ /// drivers reach Connected/Healthy in-process. Null (default) leaves the production
+ /// default in place — unchanged behaviour for existing tests.
+ public static async Task StartAsync(
+ TimeSpan? formationTimeout = null,
+ IDriverFactory? driverFactory = null)
{
var harness = new TwoNodeClusterHarness();
+ harness._driverFactory = driverFactory;
harness.NodeAAkkaPort = AllocateFreePort();
harness.NodeBAkkaPort = AllocateFreePort();
@@ -237,6 +250,18 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase(harness.SharedDbName));
}
+ // Production fidelity: Program.cs calls AddOtOpcUaRuntime() (DI registration) AND
+ // WithOtOpcUaRuntimeActors() (Akka spawn). The harness historically called only the latter,
+ // so IDriverHealthPublisher fell back to NullDriverHealthPublisher (no health on the
+ // driver-health DPS topic) and IDriverFactory to NullDriverFactory (deployed drivers never
+ // reached Connected). Register the opt-in fake factory FIRST so its registration wins over the
+ // TryAddSingleton(NullDriverFactory) seeded inside AddOtOpcUaRuntime; when no
+ // factory was passed, the Null default stays — unchanged behaviour for existing tests. Must run
+ // BEFORE AddAkka (see the AddOtOpcUaRuntime XML doc).
+ if (harness._driverFactory is not null)
+ builder.Services.AddSingleton(harness._driverFactory);
+ builder.Services.AddOtOpcUaRuntime();
+
builder.Services.AddOtOpcUaCluster(builder.Configuration);
builder.Services.AddAkka("otopcua", (ab, sp) =>