using System.Diagnostics; using Xunit; using Xunit.Sdk; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// /// Shared fixture that starts libplctag's ab_server simulator in the background for /// the duration of an integration test collection. The fixture takes an /// (see ) so each AB family — ControlLogix, /// CompactLogix, Micro800, GuardLogix — starts the simulator with the right --plc /// mode + preseed tag set. Binary is expected on PATH; CI resolves that via a job step /// that downloads the pinned Windows build from libplctag GitHub Releases before /// dotnet test — see docs/v2/test-data-sources.md §2.CI for the exact step. /// /// /// ab_server is a C binary shipped in libplctag's repo (MIT). On developer /// workstations it's built once from source and placed on PATH; on CI the workflow file /// fetches a version-pinned prebuilt + stages it. Tests skip (via /// ) when the binary is not on PATH so a fresh clone /// without the simulator still gets a green unit-test run. /// /// Per-family profiles live in . When a test wants a /// specific family, instantiate the fixture with that profile — either via a /// derived type or by constructing directly in a /// parametric test (the latter is used below for the smoke suite). /// public sealed class AbServerFixture : IAsyncLifetime { private Process? _proc; /// The profile the simulator was started with. Same instance the driver-side options should use. public AbServerProfile Profile { get; } public int Port { get; } public bool IsAvailable { get; private set; } public AbServerFixture() : this(KnownProfiles.ControlLogix, AbServerProfile.DefaultPort) { } public AbServerFixture(AbServerProfile profile) : this(profile, AbServerProfile.DefaultPort) { } public AbServerFixture(AbServerProfile profile, int port) { Profile = profile ?? throw new ArgumentNullException(nameof(profile)); Port = port; } public ValueTask InitializeAsync() => InitializeAsync(default); public ValueTask DisposeAsync() => DisposeAsync(default); public async ValueTask InitializeAsync(CancellationToken cancellationToken) { // Docker-first path: if the operator has the container running (or pointed us at a // real PLC via AB_SERVER_ENDPOINT), TCP-probe + skip the spawn. Matches the probe // patterns in ModbusSimulatorFixture / Snap7ServerFixture / OpcPlcFixture. var endpointOverride = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT"); if (endpointOverride is not null || TcpProbe("127.0.0.1", Port)) { IsAvailable = true; await Task.Delay(0, cancellationToken).ConfigureAwait(false); return; } // Fallback: no container + no override — spawn ab_server from PATH (the original // native-binary path the existing profile CLI args target). if (LocateBinary() is not string binary) { IsAvailable = false; return; } IsAvailable = true; _proc = new Process { StartInfo = new ProcessStartInfo { FileName = binary, Arguments = Profile.BuildCliArgs(Port), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }, }; _proc.Start(); // Give the server a moment to accept its listen socket before tests try to connect. await Task.Delay(500, cancellationToken).ConfigureAwait(false); } /// One-shot TCP probe; 500 ms budget so a missing server fails the probe fast. private static bool TcpProbe(string host, int port) { try { using var client = new System.Net.Sockets.TcpClient(); var task = client.ConnectAsync(host, port); return task.Wait(TimeSpan.FromMilliseconds(500)) && client.Connected; } catch { return false; } } public ValueTask DisposeAsync(CancellationToken cancellationToken) { try { if (_proc is { HasExited: false }) { _proc.Kill(entireProcessTree: true); _proc.WaitForExit(5_000); } } catch { /* best-effort cleanup */ } _proc?.Dispose(); return ValueTask.CompletedTask; } /// /// true when the AB CIP integration path has a live target: a Docker-run /// container bound to 127.0.0.1:44818, an AB_SERVER_ENDPOINT env /// override, or ab_server on PATH (native spawn fallback). /// public static bool IsServerAvailable() { if (Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT") is not null) return true; if (TcpProbe("127.0.0.1", AbServerProfile.DefaultPort)) return true; return LocateBinary() is not null; } /// /// Locate ab_server on PATH. Returns null when missing — tests that /// depend on it should use so CI runs without the binary /// simply skip rather than fail. /// public static string? LocateBinary() { var names = new[] { "ab_server.exe", "ab_server" }; var path = Environment.GetEnvironmentVariable("PATH") ?? ""; foreach (var dir in path.Split(Path.PathSeparator)) { foreach (var name in names) { var candidate = Path.Combine(dir, name); if (File.Exists(candidate)) return candidate; } } return null; } } /// /// [Fact]-equivalent that skips when neither the Docker container nor the /// native binary is available. Accepts: (a) a running listener on /// localhost:44818 (the Dockerized fixture's bind), or (b) ab_server on /// PATH for the native-spawn fallback, or (c) an explicit /// AB_SERVER_ENDPOINT env var pointing at a real PLC. /// public sealed class AbServerFactAttribute : FactAttribute { public AbServerFactAttribute() { if (!AbServerFixture.IsServerAvailable()) Skip = "ab_server not reachable. Start the Docker container " + "(docker compose -f Docker/docker-compose.yml --profile controllogix up) " + "or install libplctag test binaries."; } } /// /// [Theory]-equivalent with the same availability rules as /// . Pair with /// [MemberData(nameof(KnownProfiles.All))]-style providers to run one theory row /// per family. /// public sealed class AbServerTheoryAttribute : TheoryAttribute { public AbServerTheoryAttribute() { if (!AbServerFixture.IsServerAvailable()) Skip = "ab_server not reachable. Start the Docker container " + "(docker compose -f Docker/docker-compose.yml --profile controllogix up) " + "or install libplctag test binaries."; } }