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 partial 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 volatile 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); // Drain both pipes so the abandoned read tasks complete cleanly before disposal. try { await Task.WhenAll(stdoutTask, stderrTask).WaitAsync(TimeSpan.FromSeconds(5)); } catch { /* best-effort — we are already throwing TimeoutException */ } 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. } } }