diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index d54070b..6231325 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -51,6 +51,7 @@
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs
new file mode 100644
index 0000000..c5d01d3
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/HarnessShapeTests.cs
@@ -0,0 +1,36 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
+
+///
+/// Shape tests for the itself — these run regardless of
+/// dev-environment availability. The scenario tests in PR 5.2–5.8 carry the actual
+/// parity assertions and are guarded by .
+///
+[Collection(nameof(ParityCollection))]
+public sealed class HarnessShapeTests
+{
+ private readonly ParityHarness _h;
+ public HarnessShapeTests(ParityHarness h) => _h = h;
+
+ [Fact]
+ public void Harness_records_a_skip_reason_for_each_unavailable_backend()
+ {
+ // Either the backend resolved (driver != null, skipReason == null) or it didn't
+ // (driver == null, skipReason populated). Asserting the invariant lets the parity
+ // matrix doc (PR 5.W) faithfully report "n/a, reason: ..." for unreachable rigs.
+ (_h.LegacyDriver is null).ShouldBe(_h.LegacySkipReason is not null);
+ (_h.MxGatewayDriver is null).ShouldBe(_h.MxGatewaySkipReason is not null);
+ }
+
+ [Fact]
+ public async Task RunOnAvailableAsync_yields_one_entry_per_resolved_backend()
+ {
+ var calls = await _h.RunOnAvailableAsync(
+ (_, _) => Task.FromResult(1), CancellationToken.None);
+
+ var expected = (_h.LegacyDriver is null ? 0 : 1) + (_h.MxGatewayDriver is null ? 0 : 1);
+ calls.Count.ShouldBe(expected);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs
new file mode 100644
index 0000000..bdd362c
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ParityHarness.cs
@@ -0,0 +1,285 @@
+using System.Diagnostics;
+using System.Net.Sockets;
+using System.Reflection;
+using System.Security.Principal;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
+
+///
+/// Side-by-side fixture that drives both the legacy
+/// (talking to an out-of-process OtOpcUa.Driver.Galaxy.Host.exe) and the new
+/// in-process (talking to a running mxaccessgw
+/// gateway) against the same dev Galaxy. Phase 5 scenario tests use this harness
+/// to capture comparable snapshots from each backend.
+///
+///
+/// Per-backend availability is independent — a developer running just the legacy
+/// Galaxy.Host EXE without an mxaccessgw process up will see the legacy driver
+/// resolve and the mxgw driver mark itself unavailable. Each test decides how to
+/// handle partial availability:
+///
+/// - Strict-parity tests call to skip when either side
+/// is missing.
+/// - Single-backend smoke tests call for the backend they
+/// care about and skip with the recorded SkipReason.
+///
+/// Endpoint overrides come from environment variables so dev VMs and the central
+/// parity host can target the same suite without touching the test source:
+///
+/// - OTOPCUA_PARITY_GW_ENDPOINT — defaults to http://localhost:5120
+/// (mxaccessgw launchSettings.json http profile).
+/// - OTOPCUA_PARITY_GW_API_KEY — defaults to parity-suite-key.
+/// - OTOPCUA_PARITY_CLIENT_NAME — defaults to OtOpcUa-Parity.
+///
+///
+public sealed class ParityHarness : IAsyncLifetime
+{
+ public enum Backend { LegacyHost, MxGateway }
+
+ private const string LegacySecret = "parity-suite-secret";
+ private const string DefaultGwEndpoint = "http://localhost:5120";
+ private const string DefaultGwApiKey = "parity-suite-key";
+ private const string DefaultClientName = "OtOpcUa-Parity";
+
+ public IDriver? LegacyDriver { get; private set; }
+ public string? LegacySkipReason { get; private set; }
+
+ public IDriver? MxGatewayDriver { get; private set; }
+ public string? MxGatewaySkipReason { get; private set; }
+
+ private Process? _legacyHost;
+
+ public async ValueTask InitializeAsync()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ LegacySkipReason = "Windows-only";
+ MxGatewaySkipReason = "Windows-only";
+ return;
+ }
+
+ await InitializeLegacyAsync();
+ await InitializeMxGatewayAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ // Independent teardown — failure on one side must not prevent the other from
+ // releasing its resources (esp. the legacy Host EXE subprocess).
+ if (LegacyDriver is not null)
+ {
+ try { await LegacyDriver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ }
+ (LegacyDriver as IDisposable)?.Dispose();
+ LegacyDriver = null;
+ }
+ if (_legacyHost is not null && !_legacyHost.HasExited)
+ {
+ try { _legacyHost.Kill(entireProcessTree: true); } catch { /* ignore */ }
+ try { _legacyHost.WaitForExit(5_000); } catch { /* ignore */ }
+ }
+ _legacyHost?.Dispose();
+ _legacyHost = null;
+
+ if (MxGatewayDriver is not null)
+ {
+ try { await MxGatewayDriver.ShutdownAsync(CancellationToken.None); } catch { /* shutdown */ }
+ (MxGatewayDriver as IDisposable)?.Dispose();
+ MxGatewayDriver = null;
+ }
+ }
+
+ /// Skip the test if either backend isn't available — strict-parity scenarios.
+ public void RequireBoth()
+ {
+ if (LegacySkipReason is not null) Assert.Skip($"legacy backend unavailable: {LegacySkipReason}");
+ if (MxGatewaySkipReason is not null) Assert.Skip($"mxgateway backend unavailable: {MxGatewaySkipReason}");
+ }
+
+ /// Get a backend driver or skip if it's unavailable.
+ public IDriver GetDriver(Backend backend)
+ {
+ return backend switch
+ {
+ Backend.LegacyHost when LegacyDriver is not null => LegacyDriver,
+ Backend.LegacyHost => SkipAndThrow($"legacy backend unavailable: {LegacySkipReason}"),
+ Backend.MxGateway when MxGatewayDriver is not null => MxGatewayDriver,
+ Backend.MxGateway => SkipAndThrow($"mxgateway backend unavailable: {MxGatewaySkipReason}"),
+ _ => throw new ArgumentOutOfRangeException(nameof(backend), backend, null),
+ };
+ }
+
+ ///
+ /// Drive the same closure against every available backend. Tests use the
+ /// returned dictionary to diff snapshots — keys are the backends that
+ /// successfully resolved during . If neither
+ /// resolved, the result is empty and the test should skip.
+ ///
+ public async Task> RunOnAvailableAsync(
+ Func> scenario, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(scenario);
+ var results = new Dictionary();
+ if (LegacyDriver is not null)
+ {
+ results[Backend.LegacyHost] = await scenario(LegacyDriver, cancellationToken).ConfigureAwait(false);
+ }
+ if (MxGatewayDriver is not null)
+ {
+ results[Backend.MxGateway] = await scenario(MxGatewayDriver, cancellationToken).ConfigureAwait(false);
+ }
+ return results;
+ }
+
+ [System.Runtime.Versioning.SupportedOSPlatform("windows")]
+ private async Task InitializeLegacyAsync()
+ {
+ if (!await ZbReachableAsync())
+ {
+ LegacySkipReason = "Galaxy ZB SQL not reachable on localhost:1433";
+ return;
+ }
+ var hostExe = FindLegacyHostExe();
+ if (hostExe is null)
+ {
+ LegacySkipReason = "Galaxy.Host EXE not built — run `dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`";
+ return;
+ }
+
+ var pipe = $"OtOpcUaGalaxyParity-{Guid.NewGuid():N}";
+ using var identity = WindowsIdentity.GetCurrent();
+ var sid = identity.User!.Value;
+
+ var psi = new ProcessStartInfo(hostExe)
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ EnvironmentVariables =
+ {
+ ["OTOPCUA_GALAXY_PIPE"] = pipe,
+ ["OTOPCUA_ALLOWED_SID"] = sid,
+ ["OTOPCUA_GALAXY_SECRET"] = LegacySecret,
+ ["OTOPCUA_GALAXY_BACKEND"] = "db",
+ ["OTOPCUA_GALAXY_ZB_CONN"] = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
+ },
+ };
+
+ try
+ {
+ _legacyHost = Process.Start(psi)
+ ?? throw new InvalidOperationException("Failed to spawn Galaxy.Host EXE");
+ await Task.Delay(2_000); // PipeServer warm-up — ParityFixture's settled value
+
+ var driver = new GalaxyProxyDriver(new GalaxyProxyOptions
+ {
+ DriverInstanceId = "parity-legacy",
+ PipeName = pipe,
+ SharedSecret = LegacySecret,
+ ConnectTimeout = TimeSpan.FromSeconds(5),
+ });
+ await driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
+ LegacyDriver = driver;
+ }
+ catch (Exception ex)
+ {
+ LegacySkipReason = $"legacy backend boot failed: {ex.Message}";
+ if (_legacyHost is not null && !_legacyHost.HasExited)
+ {
+ try { _legacyHost.Kill(entireProcessTree: true); } catch { /* ignore */ }
+ }
+ }
+ }
+
+ private async Task InitializeMxGatewayAsync()
+ {
+ var endpoint = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_GW_ENDPOINT") ?? DefaultGwEndpoint;
+ var apiKey = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_GW_API_KEY") ?? DefaultGwApiKey;
+ var clientName = Environment.GetEnvironmentVariable("OTOPCUA_PARITY_CLIENT_NAME") ?? DefaultClientName;
+
+ if (!await GwReachableAsync(endpoint))
+ {
+ MxGatewaySkipReason = $"mxaccessgw not reachable at {endpoint}";
+ return;
+ }
+
+ var configJson = $$"""
+ {
+ "Gateway": {
+ "Endpoint": "{{endpoint}}",
+ "ApiKeySecretRef": "{{apiKey}}",
+ "UseTls": {{(endpoint.StartsWith("https") ? "true" : "false")}}
+ },
+ "MxAccess": { "ClientName": "{{clientName}}" }
+ }
+ """;
+
+ try
+ {
+ var driver = GalaxyDriverFactoryExtensions.CreateInstance("parity-mxgw", configJson);
+ await driver.InitializeAsync(configJson, CancellationToken.None);
+ MxGatewayDriver = driver;
+ }
+ catch (Exception ex)
+ {
+ MxGatewaySkipReason = $"mxgateway backend boot failed: {ex.GetType().Name}: {ex.Message}";
+ }
+ }
+
+ private static IDriver SkipAndThrow(string reason)
+ {
+ Assert.Skip(reason);
+ throw new UnreachableException(); // Assert.Skip throws SkipException; this satisfies the compiler
+ }
+
+ private static async Task ZbReachableAsync()
+ {
+ try
+ {
+ using var client = new TcpClient();
+ var task = client.ConnectAsync("localhost", 1433);
+ return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
+ }
+ catch { return false; }
+ }
+
+ private static async Task GwReachableAsync(string endpoint)
+ {
+ // Lightweight TCP probe — avoids spending the full gRPC connect timeout when the
+ // gateway just isn't running. We can't validate the API-key handshake here without
+ // doing a real RPC, so a successful TCP connect is the "available" signal and any
+ // auth/protocol failure surfaces during InitializeAsync below.
+ try
+ {
+ var uri = new Uri(endpoint, UriKind.Absolute);
+ using var client = new TcpClient();
+ var port = uri.Port > 0 ? uri.Port : (uri.Scheme == "https" ? 443 : 80);
+ var task = client.ConnectAsync(uri.Host, port);
+ return await Task.WhenAny(task, Task.Delay(1_500)) == task && client.Connected;
+ }
+ catch { return false; }
+ }
+
+ private static string? FindLegacyHostExe()
+ {
+ var asmDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
+ var solutionRoot = asmDir;
+ for (var i = 0; i < 8 && solutionRoot is not null; i++)
+ {
+ if (File.Exists(Path.Combine(solutionRoot, "ZB.MOM.WW.OtOpcUa.slnx"))) break;
+ solutionRoot = Path.GetDirectoryName(solutionRoot);
+ }
+ if (solutionRoot is null) return null;
+
+ var path = Path.Combine(solutionRoot,
+ "src", "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host", "bin", "Debug", "net48",
+ "OtOpcUa.Driver.Galaxy.Host.exe");
+ return File.Exists(path) ? path : null;
+ }
+}
+
+[CollectionDefinition(nameof(ParityCollection))]
+public sealed class ParityCollection : ICollectionFixture { }
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs
new file mode 100644
index 0000000..f7309d9
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/RecordingAddressSpaceBuilder.cs
@@ -0,0 +1,59 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests;
+
+///
+/// Same shape as Driver.Galaxy.E2E.RecordingAddressSpaceBuilder; duplicated
+/// here so the parity-tests project doesn't take a hard project reference on the
+/// E2E project (which would double-register E2E test classes during discovery).
+///
+public sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
+{
+ public List Folders { get; } = new();
+ public List Variables { get; } = new();
+ public List Properties { get; } = new();
+ public List AlarmConditions { get; } = new();
+ public List AlarmTransitions { get; } = new();
+
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ {
+ Folders.Add(new RecordedFolder(browseName, displayName));
+ return this;
+ }
+
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ Variables.Add(new RecordedVariable(browseName, displayName, attributeInfo));
+ return new RecordedVariableHandle(attributeInfo.FullName, AlarmConditions, AlarmTransitions);
+ }
+
+ public void AddProperty(string browseName, DriverDataType dataType, object? value)
+ => Properties.Add(new RecordedProperty(browseName, dataType, value));
+
+ public sealed record RecordedFolder(string BrowseName, string DisplayName);
+ public sealed record RecordedVariable(string BrowseName, string DisplayName, DriverAttributeInfo AttributeInfo);
+ public sealed record RecordedProperty(string BrowseName, DriverDataType DataType, object? Value);
+ public sealed record RecordedAlarmCondition(string SourceNodeId, AlarmConditionInfo Info);
+ public sealed record RecordedAlarmTransition(string SourceNodeId, AlarmEventArgs Args);
+
+ private sealed class RecordedVariableHandle(
+ string fullReference,
+ List conditions,
+ List transitions) : IVariableHandle
+ {
+ public string FullReference => fullReference;
+
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
+ {
+ conditions.Add(new RecordedAlarmCondition(fullReference, info));
+ return new RecordingSink(fullReference, transitions);
+ }
+
+ private sealed class RecordingSink(
+ string sourceNodeId, List transitions) : IAlarmConditionSink
+ {
+ public void OnTransition(AlarmEventArgs args)
+ => transitions.Add(new RecordedAlarmTransition(sourceNodeId, args));
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj
new file mode 100644
index 0000000..2ccc171
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests.csproj
@@ -0,0 +1,39 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+