using System.Diagnostics; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; /// /// Live end-to-end proof of the STAB-1 reconnect path against the real python-snap7 /// S7-1500 profile: a running survives a full PLC bounce (container /// stop/start) and resumes reading without a redeploy — the exact failure the /// arch-review flagged (a transient drop permanently killed the driver until redeploy). /// /// /// /// Double-gated so it never runs destructively by accident: /// /// — the sim must be reachable; and /// S7_RECONNECT_BOUNCE_CMD — the shell command that bounces the PLC/container /// (e.g. ssh dohertj2@10.100.0.35 docker restart otopcua-python-snap7-s7_1500). /// Absent ⇒ the test skips, because bouncing the shared sim would disrupt other suites. /// /// There is no per-test container hook in the harness, so the bounce is delegated to this /// operator-supplied command — the automation the plan called a "manual recipe", captured in /// code so it is reproducible. /// /// [Collection(Snap7ServerCollection.Name)] [Trait("Category", "Integration")] [Trait("Category", "Reconnect")] [Trait("Device", "S7_1500")] public sealed class S7_1500ReconnectTests(Snap7ServerFixture sim) { private const string BounceCmdEnvVar = "S7_RECONNECT_BOUNCE_CMD"; /// /// Reads a seeded tag Good, bounces the PLC out-of-band, observes the read fail (Degraded), /// then asserts the driver reopens on its own and the tag returns to Good — no redeploy. /// [Fact] public async Task Driver_recovers_reads_after_a_live_PLC_bounce_without_redeploy() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var bounceCmd = Environment.GetEnvironmentVariable(BounceCmdEnvVar); if (string.IsNullOrWhiteSpace(bounceCmd)) Assert.Skip($"Set {BounceCmdEnvVar} to the command that bounces the S7 sim to run this destructive test."); var ct = TestContext.Current.CancellationToken; var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port); await using var drv = new S7Driver(options, driverInstanceId: "s7-reconnect-live"); await drv.InitializeAsync("{}", ct); // Baseline — a Good read against the live sim. (await drv.ReadAsync([S7_1500Profile.ProbeTag], ct))[0].StatusCode.ShouldBe(0u); drv.GetHealth().State.ShouldBe(DriverState.Healthy); // Bounce the PLC out-of-band (stop → start), then confirm the driver both NOTICES the drop // (at least one non-Good read) and RECOVERS to Good on its own within a bounded window. await RunBounceCommandAsync(bounceCmd!, ct); var sawOutage = false; var recovered = false; var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60); while (DateTime.UtcNow < deadline) { var status = (await drv.ReadAsync([S7_1500Profile.ProbeTag], ct))[0].StatusCode; if (status != 0u) sawOutage = true; else if (sawOutage) { recovered = true; break; } await Task.Delay(TimeSpan.FromMilliseconds(500), ct); } sawOutage.ShouldBeTrue("the driver should have observed the PLC outage as a failed read"); recovered.ShouldBeTrue("the driver should have reopened the connection and resumed Good reads without a redeploy"); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } private static async Task RunBounceCommandAsync(string command, CancellationToken ct) { // Run through the login shell so an SSH/docker one-liner in the env var works verbatim. using var proc = new Process { StartInfo = new ProcessStartInfo("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }, }; proc.Start(); await proc.WaitForExitAsync(ct); proc.ExitCode.ShouldBe(0, $"bounce command failed: {await proc.StandardError.ReadToEndAsync(ct)}"); // Give the container a moment to release the port before the first post-bounce read. await Task.Delay(TimeSpan.FromSeconds(1), ct); } }