diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs new file mode 100644 index 00000000..1c40b2e9 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs @@ -0,0 +1,263 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; + +/// +/// Subprocess runner for the ScadaBridge CLI, used by state-changing Central UI +/// Playwright E2E tests to provision fixtures (sites, templates, instances, …) +/// against the running dev cluster. +/// +/// +/// Shells out to dotnet scadabridge.dll --url <Url> --username multi-role +/// --password password --format json <args>. The CLI uses Basic Auth per +/// request; multi-role/password carries Admin + Design + Deployment +/// roles. Exit code 0 = success and JSON is printed to stdout; a non-zero exit +/// surfaces as an carrying the captured +/// stderr. +/// +/// +/// +/// The built CLI assembly is named scadabridge.dll (the test project takes +/// a ReferenceOutputAssembly="false" ProjectReference on the CLI so it is +/// always built alongside the tests). honours the +/// SCADABRIDGE_CLI_DLL env override, otherwise walks up from the test +/// assembly to the repo root and probes the Debug/Release output paths. +/// +/// +public static class CliRunner +{ + /// Hard timeout for a single CLI invocation. + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60); + + private const string ManagementUrlEnvVar = "SCADABRIDGE_MANAGEMENT_URL"; + private const string CliDllEnvVar = "SCADABRIDGE_CLI_DLL"; + private const string DefaultUrl = "http://localhost:9000"; + + private const string CliProjectRelativeDir = "src/ZB.MOM.WW.ScadaBridge.CLI"; + private const string CliDllName = "scadabridge.dll"; + + private const string CliUsername = "multi-role"; + private const string CliPassword = "password"; + + private static readonly object DllLock = new(); + private static string? _cliDll; + + /// + /// Management URL the CLI connects to (via the Traefik load balancer). + /// Resolved from SCADABRIDGE_MANAGEMENT_URL when set, otherwise the + /// local docker dev default (http://localhost:9000). + /// + public static string Url + { + get + { + var fromEnv = Environment.GetEnvironmentVariable(ManagementUrlEnvVar); + return string.IsNullOrWhiteSpace(fromEnv) ? DefaultUrl : fromEnv; + } + } + + /// + /// Invokes the CLI with the given and returns its raw + /// stdout. The connection flags (--url, --username, + /// --password, --format json) are prepended automatically. + /// + /// + /// The invocation exceeded the 60-second timeout; the process tree is killed. + /// + /// + /// The CLI exited non-zero; the message carries the captured stderr. + /// + public static async Task RunAsync(params string[] args) + { + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + startInfo.ArgumentList.Add(ResolveCliDll()); + startInfo.ArgumentList.Add("--url"); + startInfo.ArgumentList.Add(Url); + startInfo.ArgumentList.Add("--username"); + startInfo.ArgumentList.Add(CliUsername); + startInfo.ArgumentList.Add("--password"); + startInfo.ArgumentList.Add(CliPassword); + startInfo.ArgumentList.Add("--format"); + startInfo.ArgumentList.Add("json"); + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + throw new InvalidOperationException( + $"Failed to start CLI process for [{string.Join(' ', args)}]."); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + using var cts = new CancellationTokenSource(Timeout); + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + TryKill(process); + throw new TimeoutException( + $"CLI [{string.Join(' ', args)}] did not exit within {Timeout.TotalSeconds:F0}s and was killed."); + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"CLI [{string.Join(' ', args)}] exited {process.ExitCode}. stderr: {stderr}"); + } + + return stdout; + } + + /// + /// Invokes the CLI with the given and parses its stdout + /// as a . The caller owns the returned document and + /// must dispose it. + /// + public static async Task RunJsonAsync(params string[] args) => + JsonDocument.Parse(await RunAsync(args)); + + /// + /// Locates the built scadabridge.dll. Honours the + /// SCADABRIDGE_CLI_DLL env override; otherwise walks up from the test + /// assembly's base directory to the repo root (the directory containing + /// src/ZB.MOM.WW.ScadaBridge.CLI) and probes the Debug/Release + /// build outputs — preferring the configuration the tests were built under. + /// The resolved path is cached. + /// + /// + /// The dll could not be found; the message lists every probed path. + /// + private static string ResolveCliDll() + { + if (_cliDll is { } cached) + { + return cached; + } + + lock (DllLock) + { + if (_cliDll is { } cachedInLock) + { + return cachedInLock; + } + + var fromEnv = Environment.GetEnvironmentVariable(CliDllEnvVar); + if (!string.IsNullOrWhiteSpace(fromEnv)) + { + if (!File.Exists(fromEnv)) + { + throw new InvalidOperationException( + $"{CliDllEnvVar} is set to '{fromEnv}' but no file exists there."); + } + + _cliDll = fromEnv; + return _cliDll; + } + + var baseDir = AppContext.BaseDirectory; + var repoRoot = FindRepoRoot(baseDir) + ?? throw new InvalidOperationException( + $"Could not locate the repo root (a directory containing '{CliProjectRelativeDir}') " + + $"by walking up from '{baseDir}'. Set {CliDllEnvVar} to the absolute path of {CliDllName}."); + + var cliProjectDir = Path.Combine(repoRoot, CliProjectRelativeDir.Replace('/', Path.DirectorySeparatorChar)); + var binDir = Path.Combine(cliProjectDir, "bin"); + + // Prefer the configuration the tests were built under (derived from + // the test assembly path: …/bin//net10.0/), then fall back. + var probeConfigs = OrderConfigs(baseDir); + + var probed = new List(); + foreach (var config in probeConfigs) + { + var candidate = Path.Combine(binDir, config, "net10.0", CliDllName); + probed.Add(candidate); + if (File.Exists(candidate)) + { + _cliDll = candidate; + return _cliDll; + } + } + + var probedList = string.Join(Environment.NewLine + " ", probed); + throw new InvalidOperationException( + $"Could not find {CliDllName}. Build the CLI (it is referenced by this test project) " + + $"or set {CliDllEnvVar}. Probed:" + Environment.NewLine + " " + probedList); + } + } + + /// + /// Walks up from looking for the repo root, i.e. + /// the first ancestor directory that contains src/ZB.MOM.WW.ScadaBridge.CLI. + /// Returns if no such ancestor exists. + /// + private static string? FindRepoRoot(string startDir) + { + var marker = CliProjectRelativeDir.Replace('/', Path.DirectorySeparatorChar); + for (var dir = new DirectoryInfo(startDir); dir is not null; dir = dir.Parent) + { + if (Directory.Exists(Path.Combine(dir.FullName, marker))) + { + return dir.FullName; + } + } + + return null; + } + + /// + /// Returns the build configurations to probe, ordered so the configuration the + /// tests were built under (inferred from the test assembly path segment, e.g. + /// …/bin/Release/net10.0/) is tried first. + /// + private static string[] OrderConfigs(string baseDir) + { + var normalized = baseDir.Replace('\\', '/'); + if (normalized.Contains("/bin/Release/", StringComparison.OrdinalIgnoreCase)) + { + return ["Release", "Debug"]; + } + + // Default to Debug-first (covers /bin/Debug/ and any unrecognised layout). + return ["Debug", "Release"]; + } + + /// + /// Best-effort kill of the CLI process and its descendants. Swallows any error + /// raised by a race with normal exit. + /// + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch + { + // Best-effort — the process may have exited between the check and the + // kill, which is the outcome we wanted anyway. + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerSmokeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerSmokeTests.cs new file mode 100644 index 00000000..b1d7f5dc --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerSmokeTests.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; + +/// +/// TDD smoke coverage for and +/// . Confirms the subprocess runner can locate +/// the built scadabridge.dll, shell out through dotnet, and parse +/// the JSON the CLI prints to stdout. When the dev cluster / MSSQL is unreachable +/// the fact reports as Skipped (not Failed), matching the established +/// ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests idiom. +/// +[Collection("Playwright")] +public class CliRunnerSmokeTests +{ + /// + /// scadabridge site list round-trips through the CLI and returns a + /// JSON document (the CLI emits a JSON array of sites in --format json). + /// + [SkippableFact] + public async Task SiteList_ReturnsJson() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + using var doc = await CliRunner.RunJsonAsync("site", "list"); + Assert.True(doc.RootElement.ValueKind is JsonValueKind.Array or JsonValueKind.Object); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/ClusterAvailability.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/ClusterAvailability.cs new file mode 100644 index 00000000..1ebedd81 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/ClusterAvailability.cs @@ -0,0 +1,54 @@ +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; + +/// +/// One-shot probe for whether the dev cluster (and its MSSQL config store) is +/// reachable through the CLI. State-changing E2E tests gate their setup on this so +/// a downed cluster surfaces as a Skipped fact (via Skip.IfNot) rather than +/// an opaque failure. The result is cached for the process; +/// is bumped each time a test would skip and is logged at suite teardown (Task 3). +/// +public static class ClusterAvailability +{ + /// Reason surfaced on skipped facts when the cluster is unavailable. + public const string SkipReason = + "Cluster/MSSQL unavailable — start the docker cluster (bash docker/deploy.sh) to run E2E."; + + private static bool? _cached; + + /// + /// Number of times a test would have skipped because the cluster is + /// unavailable. Incremented on every call that + /// returns ; logged at suite teardown (Task 3). + /// + public static int SkippedCount; + + /// + /// Returns whether the cluster is reachable, probing once (a site list + /// round-trip through the CLI) and caching the result for the process. + /// + public static async Task IsAvailableAsync() + { + if (_cached is { } cached) + { + if (!cached) + { + SkippedCount++; + } + + return cached; + } + + try + { + using var _ = await CliRunner.RunJsonAsync("site", "list"); + _cached = true; + } + catch + { + _cached = false; + SkippedCount++; + } + + return _cached.Value; + } +}