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