diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs
new file mode 100644
index 00000000..b5ad5ed2
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs
@@ -0,0 +1,350 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+
+///
+/// Typed fixture helpers over for state-changing Central
+/// UI Playwright E2E tests. Each helper shells out through
+/// / and
+/// extracts just the field the caller needs.
+///
+///
+/// Create / resolve helpers throw on failure (the underlying CLI surfaces a
+/// non-zero exit as an ); the
+/// Delete* helpers are best-effort and swallow exceptions so teardown in a
+/// finally never masks the test's own failure. The lifecycle helpers
+/// (, ,
+/// ) deliberately surface errors because tests
+/// call them as assertions about a transition succeeding.
+///
+///
+///
+/// Response shapes are empirically verified against the dev cluster: template
+/// create, template attribute add, site area create, and
+/// instance create all return a JSON object whose new primary key is the
+/// id property (an instance additionally exposes uniqueName rather
+/// than name). template list / site list return JSON arrays.
+///
+///
+public static partial class CliRunner
+{
+ ///
+ /// Builds a collision-resistant, length-bounded fixture name of the form
+ /// zztest-<kind>-<8 hex> (≤ ~22 chars). The zztest-
+ /// prefix sorts entities to the end of listings and marks them as test-owned
+ /// teardown targets.
+ ///
+ /// Short entity discriminator, e.g. "tmpl" or "inst".
+ public static string UniqueName(string kind) =>
+ $"zztest-{kind}-{Guid.NewGuid().ToString("N")[..8]}";
+
+ ///
+ /// Creates a template via template create and returns its new id.
+ ///
+ /// Template name (typically from ).
+ /// Optional template description.
+ /// The id of the newly created template.
+ ///
+ /// The CLI failed, or the response did not carry an integer id.
+ ///
+ public static async Task CreateTemplateAsync(string name, string? description = null)
+ {
+ var args = new List { "template", "create", "--name", name };
+ if (!string.IsNullOrEmpty(description))
+ {
+ args.Add("--description");
+ args.Add(description);
+ }
+
+ using var doc = await RunJsonAsync([.. args]);
+ return RequireId(doc, "template create");
+ }
+
+ ///
+ /// Adds an attribute to a template via template attribute add.
+ ///
+ /// Owning template id.
+ /// Attribute name.
+ ///
+ /// CLI data-type token; one of the DataType enum names
+ /// (Boolean, Int32, Double, String).
+ /// Defaults to Double.
+ ///
+ /// The CLI failed.
+ public static async Task AddAttributeAsync(int templateId, string name, string dataType = "Double")
+ {
+ await RunAsync(
+ "template", "attribute", "add",
+ "--template-id", templateId.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ "--name", name,
+ "--data-type", dataType);
+ }
+
+ ///
+ /// Creates an area under a site via site area create and returns its
+ /// new id.
+ ///
+ /// Owning site id.
+ /// Area name.
+ /// The id of the newly created area.
+ ///
+ /// The CLI failed, or the response did not carry an integer id.
+ ///
+ public static async Task CreateAreaAsync(int siteId, string name)
+ {
+ using var doc = await RunJsonAsync(
+ "site", "area", "create",
+ "--site-id", siteId.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ "--name", name);
+ return RequireId(doc, "site area create");
+ }
+
+ ///
+ /// Resolves a site's numeric id from its siteIdentifier (e.g.
+ /// "site-a") via site list.
+ ///
+ /// The siteIdentifier to match.
+ /// The matching site's id.
+ /// No site matched the identifier.
+ public static async Task ResolveSiteIdAsync(string identifier)
+ {
+ using var doc = await RunJsonAsync("site", "list");
+ if (doc.RootElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var site in doc.RootElement.EnumerateArray())
+ {
+ if (site.TryGetProperty("siteIdentifier", out var idtf)
+ && idtf.ValueKind == JsonValueKind.String
+ && string.Equals(idtf.GetString(), identifier, StringComparison.Ordinal)
+ && site.TryGetProperty("id", out var id)
+ && id.TryGetInt32(out var siteId))
+ {
+ return siteId;
+ }
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"No site with siteIdentifier '{identifier}' found in 'site list'.");
+ }
+
+ ///
+ /// Creates an instance via instance create and returns its new
+ /// id.
+ ///
+ /// Instance unique name.
+ /// Template the instance is created from.
+ /// Target site id.
+ /// Optional area id to place the instance under.
+ /// The id of the newly created instance.
+ ///
+ /// The CLI failed, or the response did not carry an integer id.
+ ///
+ public static async Task CreateInstanceAsync(string name, int templateId, int siteId, int? areaId = null)
+ {
+ var inv = System.Globalization.CultureInfo.InvariantCulture;
+ var args = new List
+ {
+ "instance", "create",
+ "--name", name,
+ "--template-id", templateId.ToString(inv),
+ "--site-id", siteId.ToString(inv),
+ };
+ if (areaId is { } area)
+ {
+ args.Add("--area-id");
+ args.Add(area.ToString(inv));
+ }
+
+ using var doc = await RunJsonAsync([.. args]);
+ return RequireId(doc, "instance create");
+ }
+
+ /// Deploys an instance via instance deploy. Surfaces CLI errors.
+ /// Instance id.
+ /// The CLI failed.
+ public static Task DeployInstanceAsync(int id) => RunInstanceVerbAsync("deploy", id);
+
+ /// Enables an instance via instance enable. Surfaces CLI errors.
+ /// Instance id.
+ /// The CLI failed.
+ public static Task EnableInstanceAsync(int id) => RunInstanceVerbAsync("enable", id);
+
+ /// Disables an instance via instance disable. Surfaces CLI errors.
+ /// Instance id.
+ /// The CLI failed.
+ public static Task DisableInstanceAsync(int id) => RunInstanceVerbAsync("disable", id);
+
+ ///
+ /// Best-effort delete of an instance via instance delete for teardown;
+ /// swallows any failure (the entity may already be gone).
+ ///
+ /// Instance id.
+ public static Task DeleteInstanceAsync(int id) => BestEffortAsync("instance", "delete", id);
+
+ ///
+ /// Best-effort delete of a template via template delete for teardown;
+ /// swallows any failure.
+ ///
+ /// Template id.
+ public static Task DeleteTemplateAsync(int id) => BestEffortAsync("template", "delete", id);
+
+ ///
+ /// Best-effort delete of an area via site area delete for teardown;
+ /// swallows any failure.
+ ///
+ /// Area id.
+ public static async Task DeleteAreaAsync(int id)
+ {
+ try
+ {
+ await RunAsync(
+ "site", "area", "delete",
+ "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+ catch
+ {
+ // Best-effort teardown — never mask the test's own failure.
+ }
+ }
+
+ ///
+ /// Best-effort delete of a site via site delete for teardown; swallows
+ /// any failure.
+ ///
+ /// Site id.
+ public static Task DeleteSiteAsync(int id) => BestEffortAsync("site", "delete", id);
+
+ ///
+ /// Returns the ids of all templates whose name starts with
+ /// , via template list.
+ ///
+ /// Name prefix to filter by (ordinal comparison).
+ public static async Task> ListTemplateIdsByNamePrefixAsync(string prefix)
+ {
+ using var doc = await RunJsonAsync("template", "list");
+ var ids = new List();
+ if (doc.RootElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var tmpl in doc.RootElement.EnumerateArray())
+ {
+ if (tmpl.TryGetProperty("name", out var name)
+ && name.ValueKind == JsonValueKind.String
+ && (name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)
+ && tmpl.TryGetProperty("id", out var id)
+ && id.TryGetInt32(out var templateId))
+ {
+ ids.Add(templateId);
+ }
+ }
+ }
+
+ return ids;
+ }
+
+ ///
+ /// Exports a Transport bundle scoped to a single template via
+ /// bundle export.
+ ///
+ ///
+ /// The CLI's bundle export scopes templates by name
+ /// (--templates <comma-separated names>) — there is no id-based
+ /// selector — so this resolves the template's name from
+ /// via template list and passes that
+ /// single name. Exactly one matching template must exist.
+ ///
+ ///
+ /// Destination .scadabundle path.
+ /// Id of the single template to export.
+ /// Encryption passphrase for the bundle.
+ ///
+ /// SourceEnvironment value stamped into the bundle manifest.
+ ///
+ ///
+ /// The template id could not be resolved to a name, or the CLI failed.
+ ///
+ public static async Task BundleExportAsync(
+ string outputPath, int templateId, string passphrase, string sourceEnvironment)
+ {
+ var templateName = await ResolveTemplateNameAsync(templateId);
+ await RunAsync(
+ "bundle", "export",
+ "--output", outputPath,
+ "--passphrase", passphrase,
+ "--templates", templateName,
+ "--source-environment", sourceEnvironment);
+ }
+
+ ///
+ /// Resolves a template's name from its id via template list.
+ ///
+ /// No template matched the id.
+ private static async Task ResolveTemplateNameAsync(int templateId)
+ {
+ using var doc = await RunJsonAsync("template", "list");
+ if (doc.RootElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var tmpl in doc.RootElement.EnumerateArray())
+ {
+ if (tmpl.TryGetProperty("id", out var id)
+ && id.TryGetInt32(out var foundId)
+ && foundId == templateId
+ && tmpl.TryGetProperty("name", out var name)
+ && name.ValueKind == JsonValueKind.String
+ && name.GetString() is { } templateName)
+ {
+ return templateName;
+ }
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"No template with id {templateId} found in 'template list'.");
+ }
+
+ ///
+ /// Runs an instance <verb> --id <id> command, surfacing CLI
+ /// failures to the caller.
+ ///
+ private static async Task RunInstanceVerbAsync(string verb, int id)
+ {
+ await RunAsync(
+ "instance", verb,
+ "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ ///
+ /// Runs a best-effort delete-style command, swallowing any failure so teardown
+ /// in a finally never masks the test's own outcome.
+ ///
+ private static async Task BestEffortAsync(string group, string verb, int id)
+ {
+ try
+ {
+ await RunAsync(
+ group, verb,
+ "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+ catch
+ {
+ // Best-effort teardown — the entity may already be gone.
+ }
+ }
+
+ ///
+ /// Extracts a required integer id from a create-command response,
+ /// throwing a descriptive error if it is missing or non-integral.
+ ///
+ private static int RequireId(JsonDocument doc, string command)
+ {
+ if (doc.RootElement.ValueKind == JsonValueKind.Object
+ && doc.RootElement.TryGetProperty("id", out var id)
+ && id.TryGetInt32(out var value))
+ {
+ return value;
+ }
+
+ throw new InvalidOperationException(
+ $"'{command}' response did not contain an integer 'id': {doc.RootElement.GetRawText()}");
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs
index 16d3b72d..c2299f04 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.cs
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
/// assembly to the repo root and probes the Debug/Release output paths.
///
///
-public static class CliRunner
+public static partial class CliRunner
{
/// Hard timeout for a single CLI invocation.
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs
new file mode 100644
index 00000000..672104c6
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs
@@ -0,0 +1,46 @@
+using Xunit;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+
+///
+/// TDD coverage for the typed fixture helpers used by
+/// state-changing Central UI Playwright E2E tests to provision and tear down
+/// templates, attributes, areas, and instances against the running dev cluster.
+/// When the cluster / MSSQL is unreachable the facts report as Skipped (not
+/// Failed), matching the established suite idiom.
+///
+[Collection("Playwright")]
+public class CliRunnerHelpersTests
+{
+ ///
+ /// A freshly created template is discoverable by name prefix and can be
+ /// deleted, exercising ,
+ /// , and
+ /// as a round-trip.
+ ///
+ [SkippableFact]
+ public async Task CreateThenDeleteTemplate_RoundTrips()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+ var name = CliRunner.UniqueName("tmpl");
+ int id = await CliRunner.CreateTemplateAsync(name);
+ try
+ {
+ var ids = await CliRunner.ListTemplateIdsByNamePrefixAsync(name);
+ Assert.Contains(id, ids);
+ }
+ finally { await CliRunner.DeleteTemplateAsync(id); }
+ }
+
+ ///
+ /// finds the well-known
+ /// site-a seed site by its siteIdentifier and returns a
+ /// positive id.
+ ///
+ [SkippableFact]
+ public async Task ResolveSiteA_ReturnsId()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+ Assert.True(await CliRunner.ResolveSiteIdAsync("site-a") > 0);
+ }
+}