From 7e91c37a52e9f13e85aa8f6c24d095ac740ccb2e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 8 Oct 2026 06:48:34 -0400 Subject: [PATCH] test(opcuaclient-browser): skip live browse tests when opc-plc is unreachable; move out of unit project (#594) Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe --- .../BrowseRoundTripTests.cs | 4 +- .../OpcPlcProbe.cs | 53 +++++++++++++++++++ .../OpcUaClientBrowseSessionTests.cs | 14 ++--- 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcPlcProbe.cs rename tests/Drivers/{ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests => ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests}/OpcUaClientBrowseSessionTests.cs (85%) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs index f1e1b8ab..100b9945 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/BrowseRoundTripTests.cs @@ -16,8 +16,8 @@ public class BrowseRoundTripTests [Fact] public async Task Three_level_expand_round_trips_resolve_back() { - var endpoint = Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") - ?? "opc.tcp://10.100.0.35:50000"; + OpcPlcProbe.SkipUnlessReachable(); + var endpoint = OpcPlcProbe.Endpoint; // Numeric ordinals because the production browser uses default System.Text.Json // without a JsonStringEnumConverter (SecurityPolicy.None=0, SecurityMode.None=0, // AuthType.Anonymous=0). diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcPlcProbe.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcPlcProbe.cs new file mode 100644 index 00000000..183bcd37 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcPlcProbe.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Net.Sockets; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests; + +/// +/// Reachability gate for the shared opc-plc fixture (OPCUA_SIM_ENDPOINT, default +/// opc.tcp://10.100.0.35:50000). A small copy of the probe in +/// Driver.OpcUaClient.IntegrationTests/OpcPlcFixture rather than a cross-project reference — +/// that project drags the whole driver in. Tests call first, so a +/// machine without the simulator reports Skipped instead of a connect failure. +/// +internal static class OpcPlcProbe +{ + private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT"; + private const string DefaultEndpoint = "opc.tcp://10.100.0.35:50000"; + + /// The simulator endpoint the tests should connect to. + public static string Endpoint => + Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } e ? e : DefaultEndpoint; + + private static readonly Lazy Reason = new(Probe); + + /// Skips the calling test when opc-plc does not accept a TCP connection (probed once per run). + public static void SkipUnlessReachable() + { + if (Reason.Value is { } reason) Xunit.Assert.Skip(reason); + } + + private static string? Probe() + { + var body = Endpoint.StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase) + ? Endpoint["opc.tcp://".Length..] + : Endpoint; + var slash = body.IndexOf('/'); + if (slash >= 0) body = body[..slash]; + var colon = body.LastIndexOf(':'); + var host = colon >= 0 ? body[..colon] : body; + var port = colon >= 0 && int.TryParse(body[(colon + 1)..], out var p) ? p : 4840; + try + { + using var client = new TcpClient(AddressFamily.InterNetwork); + var addr = Dns.GetHostAddresses(host).FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) + ?? IPAddress.Loopback; + if (client.ConnectAsync(addr, port).Wait(TimeSpan.FromSeconds(2)) && client.Connected) return null; + return $"opc-plc simulator at {host}:{port} did not accept a TCP connection within 2s. Start it or override {EndpointEnvVar}."; + } + catch (Exception ex) + { + return $"opc-plc simulator at {host}:{port} unreachable: {ex.GetType().Name}: {ex.Message}. Start it or override {EndpointEnvVar}."; + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs similarity index 85% rename from tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs rename to tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs index 7ea07a5e..d84794b6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests/OpcUaClientBrowseSessionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs @@ -3,21 +3,20 @@ using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser; -namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.Tests; +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests; /// /// Live-server tests against the opc-plc Docker fixture. Bring up with /// `lmxopcua-fix up opcuaclient` from PowerShell before running. These tests /// drive the internal OpcUaClientBrowseSession through its public factory /// so no InternalsVisibleTo is needed. -/// Filter out with --filter "Category!=RequiresOpcPlc" when the fixture -/// is unreachable. +/// Each test skips () when the fixture is unreachable. Moved here from the +/// unit project, which must hold no live-server tests (#594). /// -[Trait("Category", "RequiresOpcPlc")] +[Trait("Category", "Integration"), Trait("Fixture", "opc-plc")] public sealed class OpcUaClientBrowseSessionTests { - private static string Endpoint => - Environment.GetEnvironmentVariable("OPCUA_SIM_ENDPOINT") ?? "opc.tcp://10.100.0.35:50000"; + private static string Endpoint => OpcPlcProbe.Endpoint; // SecurityPolicy.None, SecurityMode.None, OpcUaAuthType.Anonymous are all // index 0 and can also be written as string names now that the browser carries @@ -40,6 +39,7 @@ public sealed class OpcUaClientBrowseSessionTests [Fact] public async Task RootAsync_returns_at_least_one_node() { + OpcPlcProbe.SkipUnlessReachable(); var ct = TestContext.Current.CancellationToken; var browser = new OpcUaClientDriverBrowser(); await using var session = await browser.OpenAsync(ConfigJson, ct); @@ -52,6 +52,7 @@ public sealed class OpcUaClientBrowseSessionTests [Fact] public async Task ExpandAsync_round_trips_stable_NodeId() { + OpcPlcProbe.SkipUnlessReachable(); var ct = TestContext.Current.CancellationToken; var browser = new OpcUaClientDriverBrowser(); await using var session = await browser.OpenAsync(ConfigJson, ct); @@ -67,6 +68,7 @@ public sealed class OpcUaClientBrowseSessionTests [Fact] public async Task AttributesAsync_is_empty_for_opcuaclient() { + OpcPlcProbe.SkipUnlessReachable(); var ct = TestContext.Current.CancellationToken; var browser = new OpcUaClientDriverBrowser(); await using var session = await browser.OpenAsync(ConfigJson, ct);