184 lines
7.4 KiB
C#
184 lines
7.4 KiB
C#
using System.Diagnostics;
|
|
using Xunit;
|
|
using Xunit.Sdk;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Shared fixture that starts libplctag's <c>ab_server</c> simulator in the background for
|
|
/// the duration of an integration test collection. The fixture takes an
|
|
/// <see cref="AbServerProfile"/> (see <see cref="KnownProfiles"/>) so each AB family — ControlLogix,
|
|
/// CompactLogix, Micro800, GuardLogix — starts the simulator with the right <c>--plc</c>
|
|
/// 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
|
|
/// <c>dotnet test</c> — see <c>docs/v2/test-data-sources.md §2.CI</c> for the exact step.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><c>ab_server</c> 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
|
|
/// <see cref="AbServerFactAttribute"/>) when the binary is not on PATH so a fresh clone
|
|
/// without the simulator still gets a green unit-test run.</para>
|
|
///
|
|
/// <para>Per-family profiles live in <see cref="KnownProfiles"/>. When a test wants a
|
|
/// specific family, instantiate the fixture with that profile — either via a
|
|
/// <see cref="IClassFixture{TFixture}"/> derived type or by constructing directly in a
|
|
/// parametric test (the latter is used below for the smoke suite).</para>
|
|
/// </remarks>
|
|
public sealed class AbServerFixture : IAsyncLifetime
|
|
{
|
|
private Process? _proc;
|
|
|
|
/// <summary>The profile the simulator was started with. Same instance the driver-side options should use.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>One-shot TCP probe; 500 ms budget so a missing server fails the probe fast.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>true</c> when the AB CIP integration path has a live target: a Docker-run
|
|
/// container bound to <c>127.0.0.1:44818</c>, an <c>AB_SERVER_ENDPOINT</c> env
|
|
/// override, or <c>ab_server</c> on <c>PATH</c> (native spawn fallback).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Locate <c>ab_server</c> on PATH. Returns <c>null</c> when missing — tests that
|
|
/// depend on it should use <see cref="AbServerFactAttribute"/> so CI runs without the binary
|
|
/// simply skip rather than fail.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>[Fact]</c>-equivalent that skips when neither the Docker container nor the
|
|
/// native binary is available. Accepts: (a) a running listener on
|
|
/// <c>localhost:44818</c> (the Dockerized fixture's bind), or (b) <c>ab_server</c> on
|
|
/// <c>PATH</c> for the native-spawn fallback, or (c) an explicit
|
|
/// <c>AB_SERVER_ENDPOINT</c> env var pointing at a real PLC.
|
|
/// </summary>
|
|
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.";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>[Theory]</c>-equivalent with the same availability rules as
|
|
/// <see cref="AbServerFactAttribute"/>. Pair with
|
|
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory row
|
|
/// per family.
|
|
/// </summary>
|
|
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.";
|
|
}
|
|
}
|