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.
///
/// This method intentionally does NOT delegate to
/// even though the behaviour is identical. models
/// two-word commands (<group> <verb>), whereas
/// site area delete is a three-word command; extracting it would require
/// changing 's signature or adding an overload.
/// The inline try/catch is kept here deliberately — if you need to fix teardown
/// logic, update both this method and any other three-word deletes together.
///
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);
// The CLI's --templates flag is comma-separated, so a name that itself
// contains a comma would silently split into multiple selectors and scope
// the export to the wrong set of templates.
if (templateName.Contains(','))
{
throw new InvalidOperationException(
$"Template name '{templateName}' contains a comma and cannot be used with '--templates'.");
}
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()}");
}
}