diff --git a/docs/drivers/OpcUaClient-Test-Fixture.md b/docs/drivers/OpcUaClient-Test-Fixture.md
index 23e67e34..c4651a54 100644
--- a/docs/drivers/OpcUaClient-Test-Fixture.md
+++ b/docs/drivers/OpcUaClient-Test-Fixture.md
@@ -52,6 +52,14 @@ the tests mock.
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
`IHostConnectivityProbe` (via the Secure Channel exchange).
+### In-process wire tests (no opc-plc)
+
+`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests` drives the real driver
+against the in-process OtOpcUa server (`ServerEventRig`, authenticating with the rig's API-key credential):
+read (+ missing node), write through the node-write gateway (+ read-only refusal, + failed-write revert),
+subscribe/unsubscribe, host-status Running -> not-Running when the server stops, and discovery. These need
+no Docker. The HistoryRead wire leg is deferred (needs an `IHistorianDataSource` stub on the server).
+
### Unit
The surface is broad because `OpcUaClientDriver` is the richest-capability
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs
index 23db27b2..bfcd4328 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDiscoveryTests.cs
@@ -5,9 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
///
-/// Scaffold tests for 's
-/// surface that don't require a live remote server. Live-browse coverage lands in a
-/// follow-up PR once the in-process OPC UA server fixture is scaffolded.
+/// Unit tests for 's
+/// surface that don't require a live remote server. Live-browse coverage lives in
+/// OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.
///
[Trait("Category", "Unit")]
public sealed class OpcUaClientDiscoveryTests
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs
index 6307a393..78cc3d26 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientDriverScaffoldTests.cs
@@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
///
/// Scaffold-level tests for that don't require a live
-/// remote OPC UA server. PR 67+ adds IReadable/IWritable/ITagDiscovery/ISubscribable
-/// tests against a local in-process OPC UA server fixture.
+/// remote OPC UA server. The IReadable/IWritable/ITagDiscovery/ISubscribable wire-level
+/// tests live in OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.
///
[Trait("Category", "Unit")]
public sealed class OpcUaClientDriverScaffoldTests
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs
index 01b132a4..1a3eba1d 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientReadWriteTests.cs
@@ -6,8 +6,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
///
/// Unit tests for the IReadable/IWritable surface that don't need a live remote OPC UA
-/// server. Wire-level round-trips against a local in-process server fixture land in a
-/// follow-up PR once we have one scaffolded.
+/// server (the uninitialised-guard contract). The wire-level read/write round-trips live in
+/// OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests, against the in-process server.
///
[Trait("Category", "Unit")]
public sealed class OpcUaClientReadWriteTests
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs
index bfde6c6a..8183a907 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientSubscribeAndProbeTests.cs
@@ -5,10 +5,9 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
///
-/// Scaffold tests for ISubscribable + IHostConnectivityProbe that don't
-/// need a live remote server. Live-session tests (subscribe/unsubscribe round-trip,
-/// keep-alive transitions) land in a follow-up PR once the in-process OPC UA server
-/// fixture is scaffolded.
+/// Unit tests for ISubscribable + IHostConnectivityProbe that don't need a
+/// live remote server. The live-session tests (subscribe/unsubscribe round-trip,
+/// keep-alive transitions) live in OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.
///
[Trait("Category", "Unit")]
public sealed class OpcUaClientSubscribeAndProbeTests
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.cs
new file mode 100644
index 00000000..63cc8b0b
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests.cs
@@ -0,0 +1,282 @@
+using System.Text.Json;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
+
+///
+/// Wire-level coverage of the real against the in-process OtOpcUa server
+/// (), replacing the "lands in a follow-up PR once we have an in-process server
+/// fixture" scaffolds in Driver.OpcUaClient.Tests (Gitea #622). No opc-plc and no Docker: every test
+/// here runs a real secure-channel + session + subscription exchange over loopback. The driver authenticates
+/// with the rig's API-key credential so writes carry the WriteOperate role.
+///
+/// HistoryRead is NOT covered here (deferred): the server-side leg needs an IHistorianDataSource
+/// stub plus a historized-variable rig seam, and the driver's passthrough shapes are unit-tested already.
+///
+///
+[Trait("Category", "Integration")]
+public sealed class OpcUaClientDriverWireTests
+{
+ private const string SpeedRef = "Sim/dev1/Speed";
+ private const string RoRef = "Sim/dev1/Counter";
+ private const string MissingRef = "Sim/dev1/Missing";
+ private const string SpeedId = ServerEventRig.RawDeviceFolder + "/Speed";
+ private const string CounterId = ServerEventRig.RawDeviceFolder + "/Counter";
+
+ private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
+ {
+ public List<(string NodeId, object? Value)> Writes { get; } = [];
+
+ public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
+ {
+ lock (Writes) Writes.Add((nodeId, value));
+ return Task.FromResult(new NodeWriteOutcome(succeed, succeed ? null : "device refused"));
+ }
+ }
+
+ private static RawTagEntry Tag(string rawPath, string nodeId) =>
+ new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true);
+
+ private static OpcUaClientDriver NewDriver(ServerEventRig rig, string id, string? browseRoot = null) =>
+ new(new OpcUaClientDriverOptions
+ {
+ EndpointUrl = rig.EndpointUrl,
+ RawTags =
+ [
+ Tag(SpeedRef, ServerEventRig.RawNsu(SpeedId)),
+ Tag(RoRef, ServerEventRig.RawNsu(CounterId)),
+ Tag(MissingRef, ServerEventRig.RawNsu($"{ServerEventRig.RawDeviceFolder}/NoSuchNode")),
+ ],
+ SecurityPolicy = OpcUaSecurityPolicy.None,
+ SecurityMode = OpcUaSecurityMode.None,
+ AuthType = OpcUaAuthType.Username,
+ Username = "apikey",
+ Password = ServerEventRig.KeyPassword,
+ AutoAcceptCertificates = true,
+ TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
+ Timeout = TimeSpan.FromSeconds(10),
+ SessionTimeout = TimeSpan.FromSeconds(30),
+ KeepAliveInterval = TimeSpan.FromSeconds(1),
+ BrowseRoot = browseRoot,
+ }, id);
+
+ private static async Task StartRigAsync(string name, CancellationToken ct)
+ {
+ var rig = await ServerEventRig.StartAsync(name, encrypted: false, ct);
+ rig.EnsureRawVariable("Speed", "Float", writable: true);
+ rig.EnsureRawVariable("Counter", "Int32", writable: false);
+ return rig;
+ }
+
+ private static void Publish(ServerEventRig rig, string id, object value) =>
+ rig.Sink.WriteValue(id, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
+
+ /// Reads round-trip through the real wire; a node the server lacks does not read Good.
+ [Fact]
+ public async Task Read_returns_the_published_value_and_a_bad_status_for_a_missing_node()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var rig = await StartRigAsync("drv-read", ct);
+ Publish(rig, SpeedId, 42.5f);
+
+ await using var drv = NewDriver(rig, "wire-read");
+ await drv.InitializeAsync("{}", ct);
+
+ var snaps = await drv.ReadAsync([SpeedRef, MissingRef], ct);
+
+ snaps.Count.ShouldBe(2);
+ snaps[0].StatusCode.ShouldBe(0u);
+ Convert.ToSingle(snaps[0].Value).ShouldBe(42.5f);
+ snaps[1].StatusCode.ShouldNotBe(0u, "a node the server does not have must not read Good");
+ }
+
+ /// A write the device accepts lands on the node and reads back.
+ [Fact]
+ public async Task Write_roundtrips_through_the_node_write_gateway_and_reads_back()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var rig = await StartRigAsync("drv-write", ct);
+ var gateway = new RecordingWriteGateway(succeed: true);
+ rig.Nm.NodeWriteGateway = gateway;
+ Publish(rig, SpeedId, 1.0f);
+
+ await using var drv = NewDriver(rig, "wire-write");
+ await drv.InitializeAsync("{}", ct);
+
+ var results = await drv.WriteAsync([new WriteRequest(SpeedRef, 7.25f)], ct);
+
+ results.Single().StatusCode.ShouldBe(0u);
+ lock (gateway.Writes) Convert.ToSingle(gateway.Writes.Single().Value).ShouldBe(7.25f);
+ var back = await drv.ReadAsync([SpeedRef], ct);
+ Convert.ToSingle(back[0].Value).ShouldBe(7.25f);
+ }
+
+ /// A write to a read-only node is refused by the server and never reaches the gateway.
+ [Fact]
+ public async Task Write_to_a_read_only_node_is_refused_and_never_reaches_the_gateway()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var rig = await StartRigAsync("drv-write-ro", ct);
+ var gateway = new RecordingWriteGateway(succeed: true);
+ rig.Nm.NodeWriteGateway = gateway;
+ Publish(rig, CounterId, 5);
+
+ await using var drv = NewDriver(rig, "wire-write-ro");
+ await drv.InitializeAsync("{}", ct);
+
+ var results = await drv.WriteAsync([new WriteRequest(RoRef, 9)], ct);
+
+ results.Single().StatusCode.ShouldNotBe(0u);
+ lock (gateway.Writes) gateway.Writes.ShouldBeEmpty();
+ // Positive control: the same session CAN write the writable node, so the refusal above is the node's.
+ (await drv.WriteAsync([new WriteRequest(SpeedRef, 2.0f)], ct)).Single().StatusCode.ShouldBe(0u);
+ lock (gateway.Writes) gateway.Writes.Count.ShouldBe(1);
+ }
+
+ /// The node manager's write-outcome self-correction is visible to the driver as a revert.
+ [Fact]
+ public async Task A_write_the_device_refuses_is_reverted_and_the_driver_reads_the_original_value()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var rig = await StartRigAsync("drv-write-fail", ct);
+ rig.Nm.NodeWriteGateway = new RecordingWriteGateway(succeed: false);
+ Publish(rig, SpeedId, 3.0f);
+
+ await using var drv = NewDriver(rig, "wire-write-fail");
+ await drv.InitializeAsync("{}", ct);
+
+ await drv.WriteAsync([new WriteRequest(SpeedRef, 99.0f)], ct);
+
+ var reverted = false;
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
+ while (DateTime.UtcNow < deadline && !reverted)
+ {
+ var snap = (await drv.ReadAsync([SpeedRef], ct))[0];
+ reverted = Math.Abs(Convert.ToSingle(snap.Value) - 3.0f) < 0.001f;
+ if (!reverted) await Task.Delay(50, ct);
+ }
+ reverted.ShouldBeTrue("the refused write must revert the node to its pre-write value");
+ }
+
+ /// A subscription delivers the initial value and then later server-side changes, and stops after unsubscribe.
+ [Fact]
+ public async Task Subscribe_delivers_the_initial_value_and_subsequent_changes_until_unsubscribed()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var rig = await StartRigAsync("drv-sub", ct);
+ Publish(rig, SpeedId, 10.0f);
+
+ await using var drv = NewDriver(rig, "wire-sub");
+ await drv.InitializeAsync("{}", ct);
+
+ var seen = new List();
+ drv.OnDataChange += (_, e) =>
+ {
+ if (e.FullReference == SpeedRef && e.Snapshot.StatusCode == 0u)
+ lock (seen) seen.Add(Convert.ToSingle(e.Snapshot.Value));
+ };
+
+ var handle = await drv.SubscribeAsync([SpeedRef], TimeSpan.FromMilliseconds(100), ct);
+ await RecordedEvents.WaitUntilAsync(() => { lock (seen) return seen.Contains(10.0f); });
+
+ Publish(rig, SpeedId, 20.0f);
+ await RecordedEvents.WaitUntilAsync(() => { lock (seen) return seen.Contains(20.0f); });
+
+ await drv.UnsubscribeAsync(handle, ct);
+
+ // Absence needs a positive control: after unsubscribing, publish 30.0 and prove a FRESH subscription
+ // delivers it exactly once, i.e. the dead first subscription contributed nothing.
+ Publish(rig, SpeedId, 30.0f);
+ var control = await drv.SubscribeAsync([SpeedRef], TimeSpan.FromMilliseconds(100), ct);
+ await RecordedEvents.WaitUntilAsync(() => { lock (seen) return seen.Contains(30.0f); });
+ await Task.Delay(500, ct);
+ await drv.UnsubscribeAsync(control, ct);
+ lock (seen) seen.Count(v => v == 30.0f).ShouldBe(1, "only the control subscription delivers 30.0");
+ }
+
+ /// The host probe reports Running while the server is up and leaves Running when it goes away.
+ [Fact]
+ public async Task Host_status_is_Running_then_leaves_Running_when_the_server_stops()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var rig = await StartRigAsync("drv-probe", ct);
+ await using var drv = NewDriver(rig, "wire-probe");
+ try
+ {
+ await drv.InitializeAsync("{}", ct);
+ drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
+
+ var transitioned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ drv.OnHostStatusChanged += (_, e) =>
+ {
+ if (e.NewState != HostState.Running) transitioned.TrySetResult(e.NewState);
+ };
+
+ await rig.DisposeAsync();
+ rig = null;
+
+ var state = await transitioned.Task.WaitAsync(TimeSpan.FromSeconds(30), ct);
+ state.ShouldNotBe(HostState.Running);
+ drv.GetHostStatuses().Single().State.ShouldNotBe(HostState.Running);
+ }
+ finally
+ {
+ if (rig is not null) await rig.DisposeAsync();
+ }
+ }
+
+ /// Discovery browses the live server's variables into the builder with their real data types.
+ [Fact]
+ public async Task Discovery_browses_the_servers_variables_into_the_builder()
+ {
+ var ct = TestContext.Current.CancellationToken;
+ await using var rig = await StartRigAsync("drv-disco", ct);
+ Publish(rig, SpeedId, 1.0f);
+
+ await using var drv = NewDriver(rig, "wire-disco", browseRoot: ServerEventRig.RawNsu(ServerEventRig.RawDeviceFolder));
+ await drv.InitializeAsync("{}", ct);
+
+ var builder = new RecordingBuilder();
+ await drv.DiscoverAsync(builder, ct);
+
+ // The server's browse name for a raw variable is its full RawPath, so key on the stable nsu= reference.
+ var byRef = builder.Variables.ToDictionary(v => v.Info.FullName);
+ var speed = byRef[ServerEventRig.RawNsu(SpeedId)].Info;
+ var counter = byRef[ServerEventRig.RawNsu(CounterId)].Info;
+ speed.DriverDataType.ShouldBe(DriverDataType.Float32);
+ counter.DriverDataType.ShouldBe(DriverDataType.Int32);
+ speed.SecurityClass.ShouldNotBe(counter.SecurityClass,
+ "the writable variable and the read-only one must classify differently");
+ }
+
+ private sealed class RecordingBuilder : IAddressSpaceBuilder
+ {
+ public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
+
+ public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
+
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ Variables.Add((browseName, attributeInfo));
+ return new Handle(attributeInfo.FullName);
+ }
+
+ public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
+
+ private sealed class Handle(string fullReference) : IVariableHandle
+ {
+ public string FullReference { get; } = fullReference;
+
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();
+ }
+
+ private sealed class Sink : IAlarmConditionSink
+ {
+ public void OnTransition(AlarmEventArgs args) { }
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
index 46a90c35..e0bf4ae1 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServerEventRig.cs
@@ -138,6 +138,24 @@ internal sealed class ServerEventRig : IAsyncDisposable
ScriptedAlarmId, EqA, "hi", "AlarmCondition", 500, AddressSpaceRealm.Uns, isNative: false);
}
+ ///
+ /// Adds a raw value variable under (the folder is ensured), for tests that
+ /// drive a real client against plain values rather than alarm conditions. Additive to the alarm helpers.
+ ///
+ public string EnsureRawVariable(string name, string dataType, bool writable)
+ {
+ Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
+ var id = $"{RawDeviceFolder}/{name}";
+ Sink.EnsureVariable(id, RawDeviceFolder, name, dataType, writable, AddressSpaceRealm.Raw);
+ return id;
+ }
+
+ /// The server-stable nsu= form of a raw-realm string NodeId.
+ public static string RawNsu(string id) => $"nsu={V3NodeIds.RawNamespaceUri};s={id}";
+
+ /// The API-key credential admits (username is always apikey).
+ public static string KeyPassword => $"otua_{KeyId}_secret";
+
public void EnsureEquipment()
{
Sink.EnsureFolder(EqA, parentNodeId: null, displayName: "A", realm: AddressSpaceRealm.Uns);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj
index 544f524c..c2b3266f 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.csproj
@@ -7,6 +7,12 @@
true
+
+
+ $(NoWarn);OTOPCUA0001
+
+
@@ -31,6 +37,8 @@
+
+