using System.Net.Sockets; using Xunit; using Xunit.Sdk; namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests; /// /// Reachability probe for a TwinCAT 3 XAR runtime on a Hyper-V VM or dedicated /// Windows box. TCP-probes ADS port 48898 on the operator-supplied host. Tests /// skip via / /// when the runtime isn't reachable, so dotnet test on a fresh clone without /// a TwinCAT VM stays green. Matches the /// / /// / /// OpcPlcFixture / AbServerFixture patterns. /// /// /// Why a VM, not a container: TwinCAT XAR bypasses the Windows /// kernel scheduler to hit real-time PLC cycles. It can't run inside Docker, and /// on bare metal it conflicts with Hyper-V / WSL 2 — that's why this repo's dev /// environment puts XAR in a dedicated Hyper-V VM per /// docs/v2/dev-environment.md §Integration host. The fixture treats the VM /// as a black-box ADS endpoint reachable over TCP. /// /// License rotation: the free XAR trial license expires every 7 days. /// When it lapses the runtime goes silent + the fixture's TCP probe fails; tests /// skip with the reason message until the operator renews via /// TcActivate.exe /reactivate (or buys a paid runtime). Intentionally surfaces /// as a skip rather than a hang because "trial expired" is operator action, not a /// test failure. /// /// Env var overrides: /// /// TWINCAT_TARGET_HOST — IP or hostname of the XAR VM (default /// localhost, assumed to be unset on the average dev box + result in a /// clean skip). /// TWINCAT_TARGET_NETID — AMS NetId the tests address (e.g. /// 5.23.91.23.1.1). Seeded on the target VM via TwinCAT System /// Manager → Routes; the dev box's AmsNetId also needs a bilateral route /// entry on the VM side. No sensible default — tests skip if unset. /// TWINCAT_TARGET_PORT — ADS target port (default 851 = /// TC3 PLC runtime 1). Set to 852 for runtime 2, etc. /// /// public sealed class TwinCATXarFixture : IAsyncLifetime { private const string HostEnvVar = "TWINCAT_TARGET_HOST"; private const string NetIdEnvVar = "TWINCAT_TARGET_NETID"; private const string PortEnvVar = "TWINCAT_TARGET_PORT"; /// ADS-over-TCP port on the XAR host. Not the PLC runtime port (that's /// ). public const int AdsTcpPort = 48898; /// TC3 PLC runtime 1. Override via . public const int DefaultAmsPort = 851; public string TargetHost { get; } public string? TargetNetId { get; } public int AmsPort { get; } public string? SkipReason { get; } public TwinCATXarFixture() { TargetHost = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost"; TargetNetId = Environment.GetEnvironmentVariable(NetIdEnvVar); AmsPort = int.TryParse(Environment.GetEnvironmentVariable(PortEnvVar), out var p) ? p : DefaultAmsPort; if (string.IsNullOrWhiteSpace(TargetNetId)) { SkipReason = $"TwinCAT XAR unreachable: {NetIdEnvVar} is not set. " + $"Start the XAR VM + set {HostEnvVar}= and {NetIdEnvVar}=."; return; } if (!TcpProbe(TargetHost, AdsTcpPort, TimeSpan.FromSeconds(2))) { SkipReason = $"TwinCAT XAR at {TargetHost}:{AdsTcpPort} not reachable within 2 s. " + $"Verify the XAR VM is running, its trial license hasn't expired " + $"(run TcActivate.exe /reactivate on the VM), and {HostEnvVar}/{NetIdEnvVar} point at it."; } } public ValueTask InitializeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; /// true when the XAR runtime is reachable + the AmsNetId is set. /// Used by the skip attributes to avoid spinning up the fixture for every test /// class. public static bool IsRuntimeAvailable() { if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(NetIdEnvVar))) return false; var host = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost"; return TcpProbe(host, AdsTcpPort, TimeSpan.FromMilliseconds(500)); } private static bool TcpProbe(string host, int port, TimeSpan timeout) { try { using var client = new TcpClient(); var task = client.ConnectAsync(host, port); return task.Wait(timeout) && client.Connected; } catch { return false; } } } [Xunit.CollectionDefinition(Name)] public sealed class TwinCATXarCollection : Xunit.ICollectionFixture { public const string Name = "TwinCATXar"; } /// [Fact]-equivalent gated on . public sealed class TwinCATFactAttribute : FactAttribute { public TwinCATFactAttribute() { if (!TwinCATXarFixture.IsRuntimeAvailable()) Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " + "for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset."; } } /// [Theory]-equivalent with the same gate as . public sealed class TwinCATTheoryAttribute : TheoryAttribute { public TwinCATTheoryAttribute() { if (!TwinCATXarFixture.IsRuntimeAvailable()) Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " + "for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset."; } }