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.
///
///
/// Optional data source reference (tag path). When provided, maps to
/// --data-source on the CLI and sets
/// TemplateAttribute.DataSourceReference. The InstanceConfigure page
/// populates _bindingDataSourceAttrs by filtering attributes to those
/// where DataSourceReference is non-empty, so an attribute that needs
/// to appear in the Connection Bindings panel MUST be created with this set.
///
/// The CLI failed.
public static async Task AddAttributeAsync(
int templateId, string name, string dataType = "Double",
string? dataSourceReference = null)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
var args = new List
{
"template", "attribute", "add",
"--template-id", templateId.ToString(inv),
"--name", name,
"--data-type", dataType,
};
if (!string.IsNullOrEmpty(dataSourceReference))
{
args.Add("--data-source");
args.Add(dataSourceReference);
}
await RunAsync([.. args]);
}
///
/// 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.
}
}
///
/// Returns the ids of all areas on whose name
/// starts with , via site area list --site-id.
/// Used to delete areas a test created through the UI (where the new id is never
/// surfaced to the test).
///
///
/// Response shape: each element of the returned JSON array carries an integer
/// id and a string name (empirically verified against the dev
/// cluster — same shapes as the other create/list helpers in this file).
///
///
/// Owning site id.
///
/// Name prefix to filter by (ordinal comparison). Typically the full unique name
/// produced by so exactly one id is returned.
///
///
/// The ids of every area on whose name starts with
/// ; empty if no areas match.
///
public static async Task> ListAreaIdsByNamePrefixAsync(int siteId, string prefix)
{
using var doc = await RunJsonAsync(
"site", "area", "list",
"--site-id", siteId.ToString(System.Globalization.CultureInfo.InvariantCulture));
var ids = new List();
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var area in doc.RootElement.EnumerateArray())
{
if (area.TryGetProperty("name", out var name)
&& name.ValueKind == JsonValueKind.String
&& (name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)
&& area.TryGetProperty("id", out var id)
&& id.TryGetInt32(out var areaId))
{
ids.Add(areaId);
}
}
}
return ids;
}
///
/// 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);
///
/// Creates a data connection on a site via data-connection create and returns its new id.
///
public static async Task CreateDataConnectionAsync(int siteId, string name, string protocol = "OpcUa", string? primaryConfig = null)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
var args = new List
{
"data-connection", "create",
"--site-id", siteId.ToString(inv),
"--name", name,
"--protocol", protocol,
};
if (!string.IsNullOrEmpty(primaryConfig))
{
args.Add("--primary-config");
args.Add(primaryConfig);
}
using var doc = await RunJsonAsync([.. args]);
return RequireId(doc, "data-connection create");
}
/// Best-effort delete of a data connection via data-connection delete for teardown.
public static Task DeleteDataConnectionAsync(int id) => BestEffortAsync("data-connection", "delete", id);
///
/// Creates an inbound API method via api-method create (so it appears as a checkbox in the
/// API-key form) and returns its new id.
///
public static async Task CreateApiMethodAsync(string name, string script = "return null;")
{
using var doc = await RunJsonAsync("api-method", "create", "--name", name, "--script", script);
return RequireId(doc, "api-method create");
}
/// Best-effort delete of an API method via api-method delete for teardown.
public static Task DeleteApiMethodAsync(int id) => BestEffortAsync("api-method", "delete", id);
///
/// Creates an API key via security api-key create and returns its opaque string
/// keyId (resolved by name from security api-key list).
///
///
/// security api-key create prints a human-readable block (not JSON) even under
/// --format json, so this uses (never
/// RunJsonAsync, which would throw a JsonException on that output) and then
/// resolves the new key's id by its unique name. Use this for tests that need a
/// pre-existing key to act on (enable/disable/delete); pair with
/// for teardown.
///
/// Unique key name (typically from ).
/// Comma-separated API method names the key is scoped to.
/// The new key's opaque keyId.
///
/// The CLI failed, or the key could not be resolved by name after creation.
///
public static async Task CreateApiKeyAsync(string name, string methods)
{
await RunAsync("security", "api-key", "create", "--name", name, "--methods", methods);
return await ResolveApiKeyIdByNameAsync(name)
?? throw new InvalidOperationException(
$"API key '{name}' was created but could not be resolved by name in 'security api-key list'.");
}
///
/// Resolves an API key's opaque string keyId from its display name via
/// security api-key list; returns if no key matches.
///
public static async Task ResolveApiKeyIdByNameAsync(string name)
{
using var doc = await RunJsonAsync("security", "api-key", "list");
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var key in doc.RootElement.EnumerateArray())
{
if (key.TryGetProperty("name", out var n)
&& n.ValueKind == JsonValueKind.String
&& string.Equals(n.GetString(), name, StringComparison.Ordinal)
&& key.TryGetProperty("keyId", out var k)
&& k.ValueKind == JsonValueKind.String)
{
return k.GetString();
}
}
}
return null;
}
///
/// Best-effort delete of an API key via security api-key delete --key-id for teardown.
/// The key id is an opaque string, so this cannot use the int-based .
///
public static async Task DeleteApiKeyAsync(string keyId)
{
try
{
await RunAsync("security", "api-key", "delete", "--key-id", keyId);
}
catch
{
// Best-effort teardown — never mask the test's own failure.
}
}
///
/// Reads an instance's full configuration via instance get; the returned document exposes
/// connectionBindings, attributeOverrides, and areaId for persistence read-back.
///
///
/// This is the only helper that hands back a live (the rest return
/// scalars). The caller OWNS it and MUST wrap the call in using var doc = …; the
/// Document suffix is the signal that this returns a disposable resource, not plain data.
///
public static Task GetInstanceDocumentAsync(int id) =>
RunJsonAsync("instance", "get", "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
///
/// 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;
}
///
/// Creates an external system via external-system create and returns its new id.
///
/// External system name (typically from ).
/// Endpoint base URL (defaults to an unreachable placeholder).
/// Auth type token; one of ApiKey or BasicAuth.
///
/// The CLI failed, or the response did not carry an integer id.
///
public static async Task CreateExternalSystemAsync(
string name, string endpointUrl = "https://example.invalid/api", string authType = "ApiKey")
{
using var doc = await RunJsonAsync(
"external-system", "create",
"--name", name, "--endpoint-url", endpointUrl, "--auth-type", authType);
return RequireId(doc, "external-system create");
}
///
/// Creates a notification list via notification create and returns its new id.
///
/// Notification list name (typically from ).
/// Comma-separated recipient emails.
///
/// The CLI failed, or the response did not carry an integer id.
///
public static async Task CreateNotificationListAsync(string name, string emails = "noreply@example.invalid")
{
using var doc = await RunJsonAsync("notification", "create", "--name", name, "--emails", emails);
return RequireId(doc, "notification create");
}
///
/// Creates a shared script via shared-script create and returns its new id.
///
/// Shared script name (typically from ).
/// Script body.
///
/// The CLI failed, or the response did not carry an integer id.
///
public static async Task CreateSharedScriptAsync(string name, string code = "return null;")
{
using var doc = await RunJsonAsync("shared-script", "create", "--name", name, "--code", code);
return RequireId(doc, "shared-script create");
}
///
/// Creates an LDAP→role mapping via security role-mapping create and returns its new id.
///
/// LDAP group name to map (typically from ).
/// Role to grant members of the group; defaults to Designer.
///
/// The CLI failed, or the response did not carry an integer id.
///
public static async Task CreateRoleMappingAsync(string ldapGroup, string role = "Designer")
{
using var doc = await RunJsonAsync(
"security", "role-mapping", "create",
"--ldap-group", ldapGroup, "--role", role);
return RequireId(doc, "security role-mapping create");
}
///
/// Returns the ids of all external systems whose name starts with
/// , via external-system list. Used to delete an
/// external system a test created through the UI (where the new id is never surfaced).
///
/// Name prefix to filter by (ordinal comparison).
public static async Task> ListExternalSystemIdsByNamePrefixAsync(string prefix)
{
using var doc = await RunJsonAsync("external-system", "list");
return IdsWhereNameStartsWith(doc, prefix);
}
///
/// Returns the ids of all notification lists whose name starts with
/// , via notification list. Used to delete a
/// notification list a test created through the UI (where the new id is never surfaced).
///
/// Name prefix to filter by (ordinal comparison).
public static async Task> ListNotificationListIdsByNamePrefixAsync(string prefix)
{
using var doc = await RunJsonAsync("notification", "list");
return IdsWhereNameStartsWith(doc, prefix);
}
/// Best-effort delete of an external system via external-system delete for teardown.
public static Task DeleteExternalSystemAsync(int id) => BestEffortAsync("external-system", "delete", id);
/// Best-effort delete of a notification list via notification delete for teardown.
public static Task DeleteNotificationListAsync(int id) => BestEffortAsync("notification", "delete", id);
/// Best-effort delete of a shared script via shared-script delete for teardown.
public static Task DeleteSharedScriptAsync(int id) => BestEffortAsync("shared-script", "delete", id);
///
/// Best-effort delete of an LDAP→role mapping via security role-mapping delete for teardown;
/// swallows any failure (the entity may already be gone).
///
/// Role-mapping id.
///
/// This method intentionally does NOT delegate to
/// even though the behaviour is identical. models
/// two-word commands (<group> <verb>), whereas
/// security role-mapping 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 — same pattern as
/// .
///
public static async Task DeleteRoleMappingAsync(int id)
{
try
{
await RunAsync(
"security", "role-mapping", "delete",
"--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
catch
{
// Best-effort teardown — never mask the test's own failure.
}
}
///
/// 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.
}
}
///
/// Returns the integer ids of every element in a JSON array document whose
/// name starts with (ordinal comparison),
/// tolerating both camelCase (id/name) and PascalCase
/// (Id/Name) keys. Shared by the external-system and
/// notification-list list helpers, whose list responses use PascalCase.
///
private static IReadOnlyList IdsWhereNameStartsWith(JsonDocument doc, string prefix)
{
var ids = new List();
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var el in doc.RootElement.EnumerateArray())
{
if (!el.TryGetProperty("name", out var name) && !el.TryGetProperty("Name", out name)) continue;
if (name.ValueKind != JsonValueKind.String) continue;
if (!(name.GetString()?.StartsWith(prefix, StringComparison.Ordinal) ?? false)) continue;
if ((el.TryGetProperty("id", out var id) || el.TryGetProperty("Id", out id)) &&
id.TryGetInt32(out var n)) ids.Add(n);
}
}
return ids;
}
///
/// 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()}");
}
}