using System.Net.Sockets; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; /// /// Reachability probe for an opc-plc simulator (Microsoft Industrial IoT's /// OPC UA PLC from mcr.microsoft.com/iotedge/opc-plc) or any real OPC UA /// server the OPCUA_SIM_ENDPOINT env var points at. Parses /// OPCUA_SIM_ENDPOINT (default opc.tcp://localhost:50000), /// TCP-connects to the resolved host:port at collection init, and records a /// on failure. Tests call Assert.Skip on that, so /// `dotnet test` stays green when Docker isn't running the simulator — mirrors the /// / Snap7ServerFixture pattern. /// /// /// /// Why opc-plc over loopback against our own server — (1) independent /// cert chain + user-token handling catches interop bugs loopback can't; /// (2) built-in alarm ConditionType + history simulation gives /// + /// coverage without a custom /// driver fake; (3) pinned image tag fixes the test surface in a way our own /// evolving server wouldn't. Follow-up: add open62541/open62541 as a /// second image once this lands, for fully-independent-stack interop. /// /// /// Endpoint URL contract: parser strips the opc.tcp:// scheme + resolves /// host + port for the liveness probe only. The real test session always /// dials the full endpoint URL via /// so cert negotiation + security-policy selection run end-to-end. /// /// public sealed class OpcPlcFixture : IAsyncDisposable { private const string DefaultEndpoint = "opc.tcp://localhost:50000"; private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT"; /// Full opc.tcp://host:port URL the driver session should connect to. public string EndpointUrl { get; } public string Host { get; } public int Port { get; } public string? SkipReason { get; } public OpcPlcFixture() { EndpointUrl = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint; (Host, Port) = ParseHostPort(EndpointUrl); try { using var client = new TcpClient(AddressFamily.InterNetwork); var task = client.ConnectAsync( System.Net.Dns.GetHostAddresses(Host) .FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? System.Net.IPAddress.Loopback, Port); if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected) { SkipReason = $"opc-plc simulator at {Host}:{Port} did not accept a TCP connection within 2s. " + $"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}."; } } catch (Exception ex) { SkipReason = $"opc-plc simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " + $"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}."; } } /// /// Parse "opc.tcp://host:port[/path]" → (host, port). Defaults to port 4840 /// (OPC UA standard) when the URL omits the port, but opc-plc's default is /// 50000 so DefaultEndpoint carries it explicitly. /// private static (string Host, int Port) ParseHostPort(string endpointUrl) { const string scheme = "opc.tcp://"; var body = endpointUrl.StartsWith(scheme, StringComparison.OrdinalIgnoreCase) ? endpointUrl[scheme.Length..] : endpointUrl; var slash = body.IndexOf('/'); if (slash >= 0) body = body[..slash]; var colon = body.IndexOf(':'); if (colon < 0) return (body, 4840); var host = body[..colon]; return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, 4840); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } [Xunit.CollectionDefinition(Name)] public sealed class OpcPlcCollection : Xunit.ICollectionFixture { public const string Name = "OpcPlc"; }