267 lines
10 KiB
C#
267 lines
10 KiB
C#
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para>
|
|
/// Shells out to <c>dotnet scadabridge.dll --url <Url> --username multi-role
|
|
/// --password password --format json <args></c>. The CLI uses Basic Auth per
|
|
/// request; <c>multi-role</c>/<c>password</c> carries Admin + Design + Deployment
|
|
/// roles. Exit code 0 = success and JSON is printed to stdout; a non-zero exit
|
|
/// surfaces as an <see cref="InvalidOperationException"/> carrying the captured
|
|
/// stderr.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The built CLI assembly is named <c>scadabridge.dll</c> (the test project takes
|
|
/// a <c>ReferenceOutputAssembly="false"</c> ProjectReference on the CLI so it is
|
|
/// always built alongside the tests). <see cref="ResolveCliDll"/> honours the
|
|
/// <c>SCADABRIDGE_CLI_DLL</c> env override, otherwise walks up from the test
|
|
/// assembly to the repo root and probes the Debug/Release output paths.
|
|
/// </para>
|
|
/// </summary>
|
|
public static partial class CliRunner
|
|
{
|
|
/// <summary>Hard timeout for a single CLI invocation.</summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Management URL the CLI connects to (via the Traefik load balancer).
|
|
/// Resolved from <c>SCADABRIDGE_MANAGEMENT_URL</c> when set, otherwise the
|
|
/// local docker dev default (<c>http://localhost:9000</c>).
|
|
/// </summary>
|
|
public static string Url
|
|
{
|
|
get
|
|
{
|
|
var fromEnv = Environment.GetEnvironmentVariable(ManagementUrlEnvVar);
|
|
return string.IsNullOrWhiteSpace(fromEnv) ? DefaultUrl : fromEnv;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invokes the CLI with the given <paramref name="args"/> and returns its raw
|
|
/// stdout. The connection flags (<c>--url</c>, <c>--username</c>,
|
|
/// <c>--password</c>, <c>--format json</c>) are prepended automatically.
|
|
/// </summary>
|
|
/// <exception cref="TimeoutException">
|
|
/// The invocation exceeded the 60-second timeout; the process tree is killed.
|
|
/// </exception>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// The CLI exited non-zero; the message carries the captured stderr.
|
|
/// </exception>
|
|
public static async Task<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invokes the CLI with the given <paramref name="args"/> and parses its stdout
|
|
/// as a <see cref="JsonDocument"/>. The caller owns the returned document and
|
|
/// must dispose it.
|
|
/// </summary>
|
|
public static async Task<JsonDocument> RunJsonAsync(params string[] args) =>
|
|
JsonDocument.Parse(await RunAsync(args));
|
|
|
|
/// <summary>
|
|
/// Locates the built <c>scadabridge.dll</c>. Honours the
|
|
/// <c>SCADABRIDGE_CLI_DLL</c> env override; otherwise walks up from the test
|
|
/// assembly's base directory to the repo root (the directory containing
|
|
/// <c>src/ZB.MOM.WW.ScadaBridge.CLI</c>) and probes the <c>Debug</c>/<c>Release</c>
|
|
/// build outputs — preferring the configuration the tests were built under.
|
|
/// The resolved path is cached.
|
|
/// </summary>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// The dll could not be found; the message lists every probed path.
|
|
/// </exception>
|
|
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/<config>/net10.0/), then fall back.
|
|
var probeConfigs = OrderConfigs(baseDir);
|
|
|
|
var probed = new List<string>();
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks up from <paramref name="startDir"/> looking for the repo root, i.e.
|
|
/// the first ancestor directory that contains <c>src/ZB.MOM.WW.ScadaBridge.CLI</c>.
|
|
/// Returns <see langword="null"/> if no such ancestor exists.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the build configurations to probe, ordered so the configuration the
|
|
/// tests were built under (inferred from the test assembly path segment, e.g.
|
|
/// <c>…/bin/Release/net10.0/</c>) is tried first.
|
|
/// </summary>
|
|
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"];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Best-effort kill of the CLI process and its descendants. Swallows any error
|
|
/// raised by a race with normal exit.
|
|
/// </summary>
|
|
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.
|
|
}
|
|
}
|
|
}
|