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.";
}
}