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;
+ }
+}