Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs
T
Joseph Doherty 1afb3670c5 test(e2e): add ListAreaIdsByNamePrefixAsync CLI helper for UI-created area teardown
Adds CliRunner.ListAreaIdsByNamePrefixAsync so Playwright tests can locate
and delete areas they created through the UI without needing the id returned
from a CLI create call. Mirrors ListTemplateIdsByNamePrefixAsync. Round-trip
test verifies create → list-by-prefix → delete against the live cluster.
2026-06-06 13:20:11 -04:00

554 lines
24 KiB
C#

using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
/// <summary>
/// Typed fixture helpers over <see cref="CliRunner"/> for state-changing Central
/// UI Playwright E2E tests. Each helper shells out through
/// <see cref="CliRunner.RunJsonAsync"/> / <see cref="CliRunner.RunAsync"/> and
/// extracts just the field the caller needs.
///
/// <para>
/// Create / resolve helpers throw on failure (the underlying CLI surfaces a
/// non-zero exit as an <see cref="InvalidOperationException"/>); the
/// <c>Delete*</c> helpers are best-effort and swallow exceptions so teardown in a
/// <c>finally</c> never masks the test's own failure. The lifecycle helpers
/// (<see cref="DeployInstanceAsync"/>, <see cref="EnableInstanceAsync"/>,
/// <see cref="DisableInstanceAsync"/>) deliberately surface errors because tests
/// call them as assertions about a transition succeeding.
/// </para>
///
/// <para>
/// Response shapes are empirically verified against the dev cluster: <c>template
/// create</c>, <c>template attribute add</c>, <c>site area create</c>, and
/// <c>instance create</c> all return a JSON object whose new primary key is the
/// <c>id</c> property (an instance additionally exposes <c>uniqueName</c> rather
/// than <c>name</c>). <c>template list</c> / <c>site list</c> return JSON arrays.
/// </para>
/// </summary>
public static partial class CliRunner
{
/// <summary>
/// Builds a collision-resistant, length-bounded fixture name of the form
/// <c>zztest-&lt;kind&gt;-&lt;8 hex&gt;</c> (≤ ~22 chars). The <c>zztest-</c>
/// prefix sorts entities to the end of listings and marks them as test-owned
/// teardown targets.
/// </summary>
/// <param name="kind">Short entity discriminator, e.g. <c>"tmpl"</c> or <c>"inst"</c>.</param>
public static string UniqueName(string kind) =>
$"zztest-{kind}-{Guid.NewGuid().ToString("N")[..8]}";
/// <summary>
/// Creates a template via <c>template create</c> and returns its new <c>id</c>.
/// </summary>
/// <param name="name">Template name (typically from <see cref="UniqueName"/>).</param>
/// <param name="description">Optional template description.</param>
/// <returns>The id of the newly created template.</returns>
/// <exception cref="InvalidOperationException">
/// The CLI failed, or the response did not carry an integer <c>id</c>.
/// </exception>
public static async Task<int> CreateTemplateAsync(string name, string? description = null)
{
var args = new List<string> { "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");
}
/// <summary>
/// Adds an attribute to a template via <c>template attribute add</c>.
/// </summary>
/// <param name="templateId">Owning template id.</param>
/// <param name="name">Attribute name.</param>
/// <param name="dataType">
/// CLI data-type token; one of the <c>DataType</c> enum names
/// (<c>Boolean</c>, <c>Int32</c>, <c>Double</c>, <c>String</c>).
/// Defaults to <c>Double</c>.
/// </param>
/// <param name="dataSourceReference">
/// Optional data source reference (tag path). When provided, maps to
/// <c>--data-source</c> on the CLI and sets
/// <c>TemplateAttribute.DataSourceReference</c>. The InstanceConfigure page
/// populates <c>_bindingDataSourceAttrs</c> by filtering attributes to those
/// where <c>DataSourceReference</c> is non-empty, so an attribute that needs
/// to appear in the Connection Bindings panel MUST be created with this set.
/// </param>
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
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<string>
{
"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]);
}
/// <summary>
/// Creates an area under a site via <c>site area create</c> and returns its
/// new <c>id</c>.
/// </summary>
/// <param name="siteId">Owning site id.</param>
/// <param name="name">Area name.</param>
/// <returns>The id of the newly created area.</returns>
/// <exception cref="InvalidOperationException">
/// The CLI failed, or the response did not carry an integer <c>id</c>.
/// </exception>
public static async Task<int> 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");
}
/// <summary>
/// Resolves a site's numeric id from its <c>siteIdentifier</c> (e.g.
/// <c>"site-a"</c>) via <c>site list</c>.
/// </summary>
/// <param name="identifier">The <c>siteIdentifier</c> to match.</param>
/// <returns>The matching site's id.</returns>
/// <exception cref="InvalidOperationException">No site matched the identifier.</exception>
public static async Task<int> 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'.");
}
/// <summary>
/// Creates an instance via <c>instance create</c> and returns its new
/// <c>id</c>.
/// </summary>
/// <param name="name">Instance unique name.</param>
/// <param name="templateId">Template the instance is created from.</param>
/// <param name="siteId">Target site id.</param>
/// <param name="areaId">Optional area id to place the instance under.</param>
/// <returns>The id of the newly created instance.</returns>
/// <exception cref="InvalidOperationException">
/// The CLI failed, or the response did not carry an integer <c>id</c>.
/// </exception>
public static async Task<int> CreateInstanceAsync(string name, int templateId, int siteId, int? areaId = null)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
var args = new List<string>
{
"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");
}
/// <summary>Deploys an instance via <c>instance deploy</c>. Surfaces CLI errors.</summary>
/// <param name="id">Instance id.</param>
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
public static Task DeployInstanceAsync(int id) => RunInstanceVerbAsync("deploy", id);
/// <summary>Enables an instance via <c>instance enable</c>. Surfaces CLI errors.</summary>
/// <param name="id">Instance id.</param>
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
public static Task EnableInstanceAsync(int id) => RunInstanceVerbAsync("enable", id);
/// <summary>Disables an instance via <c>instance disable</c>. Surfaces CLI errors.</summary>
/// <param name="id">Instance id.</param>
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
public static Task DisableInstanceAsync(int id) => RunInstanceVerbAsync("disable", id);
/// <summary>
/// Best-effort delete of an instance via <c>instance delete</c> for teardown;
/// swallows any failure (the entity may already be gone).
/// </summary>
/// <param name="id">Instance id.</param>
public static Task DeleteInstanceAsync(int id) => BestEffortAsync("instance", "delete", id);
/// <summary>
/// Best-effort delete of a template via <c>template delete</c> for teardown;
/// swallows any failure.
/// </summary>
/// <param name="id">Template id.</param>
public static Task DeleteTemplateAsync(int id) => BestEffortAsync("template", "delete", id);
/// <summary>
/// Best-effort delete of an area via <c>site area delete</c> for teardown;
/// swallows any failure.
/// </summary>
/// <param name="id">Area id.</param>
/// <remarks>
/// This method intentionally does NOT delegate to <see cref="BestEffortAsync"/>
/// even though the behaviour is identical. <see cref="BestEffortAsync"/> models
/// two-word commands (<c>&lt;group&gt; &lt;verb&gt;</c>), whereas
/// <c>site area delete</c> is a three-word command; extracting it would require
/// changing <see cref="BestEffortAsync"/>'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.
/// </remarks>
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.
}
}
/// <summary>
/// Returns the ids of all areas on <paramref name="siteId"/> whose <c>name</c>
/// starts with <paramref name="prefix"/>, via <c>site area list --site-id</c>.
/// Used to delete areas a test created through the UI (where the new id is never
/// surfaced to the test).
///
/// <para>
/// Response shape: each element of the returned JSON array carries an integer
/// <c>id</c> and a string <c>name</c> (empirically verified against the dev
/// cluster — same shapes as the other create/list helpers in this file).
/// </para>
/// </summary>
/// <param name="siteId">Owning site id.</param>
/// <param name="prefix">
/// Name prefix to filter by (ordinal comparison). Typically the full unique name
/// produced by <see cref="UniqueName"/> so exactly one id is returned.
/// </param>
/// <returns>
/// The ids of every area on <paramref name="siteId"/> whose name starts with
/// <paramref name="prefix"/>; empty if no areas match.
/// </returns>
public static async Task<IReadOnlyList<int>> 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<int>();
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;
}
/// <summary>
/// Best-effort delete of a site via <c>site delete</c> for teardown; swallows
/// any failure.
/// </summary>
/// <param name="id">Site id.</param>
public static Task DeleteSiteAsync(int id) => BestEffortAsync("site", "delete", id);
/// <summary>
/// Creates a data connection on a site via <c>data-connection create</c> and returns its new <c>id</c>.
/// </summary>
public static async Task<int> CreateDataConnectionAsync(int siteId, string name, string protocol = "OpcUa", string? primaryConfig = null)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
var args = new List<string>
{
"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");
}
/// <summary>Best-effort delete of a data connection via <c>data-connection delete</c> for teardown.</summary>
public static Task DeleteDataConnectionAsync(int id) => BestEffortAsync("data-connection", "delete", id);
/// <summary>
/// Creates an inbound API method via <c>api-method create</c> (so it appears as a checkbox in the
/// API-key form) and returns its new <c>id</c>.
/// </summary>
public static async Task<int> 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");
}
/// <summary>Best-effort delete of an API method via <c>api-method delete</c> for teardown.</summary>
public static Task DeleteApiMethodAsync(int id) => BestEffortAsync("api-method", "delete", id);
/// <summary>
/// Creates an API key via <c>security api-key create</c> and returns its opaque string
/// <c>keyId</c> (resolved by name from <c>security api-key list</c>).
/// </summary>
/// <remarks>
/// <c>security api-key create</c> prints a human-readable block (not JSON) even under
/// <c>--format json</c>, so this uses <see cref="CliRunner.RunAsync"/> (never
/// <c>RunJsonAsync</c>, which would throw a <c>JsonException</c> 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
/// <see cref="DeleteApiKeyAsync"/> for teardown.
/// </remarks>
/// <param name="name">Unique key name (typically from <see cref="UniqueName"/>).</param>
/// <param name="methods">Comma-separated API method names the key is scoped to.</param>
/// <returns>The new key's opaque <c>keyId</c>.</returns>
/// <exception cref="InvalidOperationException">
/// The CLI failed, or the key could not be resolved by name after creation.
/// </exception>
public static async Task<string> 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'.");
}
/// <summary>
/// Resolves an API key's opaque string <c>keyId</c> from its display name via
/// <c>security api-key list</c>; returns <see langword="null"/> if no key matches.
/// </summary>
public static async Task<string?> 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;
}
/// <summary>
/// Best-effort delete of an API key via <c>security api-key delete --key-id</c> for teardown.
/// The key id is an opaque string, so this cannot use the int-based <see cref="BestEffortAsync"/>.
/// </summary>
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.
}
}
/// <summary>
/// Reads an instance's full configuration via <c>instance get</c>; the returned document exposes
/// <c>connectionBindings</c>, <c>attributeOverrides</c>, and <c>areaId</c> for persistence read-back.
/// </summary>
/// <remarks>
/// This is the only helper that hands back a live <see cref="JsonDocument"/> (the rest return
/// scalars). The caller OWNS it and MUST wrap the call in <c>using var doc = …</c>; the
/// <c>Document</c> suffix is the signal that this returns a disposable resource, not plain data.
/// </remarks>
public static Task<JsonDocument> GetInstanceDocumentAsync(int id) =>
RunJsonAsync("instance", "get", "--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
/// <summary>
/// Returns the ids of all templates whose <c>name</c> starts with
/// <paramref name="prefix"/>, via <c>template list</c>.
/// </summary>
/// <param name="prefix">Name prefix to filter by (ordinal comparison).</param>
public static async Task<IReadOnlyList<int>> ListTemplateIdsByNamePrefixAsync(string prefix)
{
using var doc = await RunJsonAsync("template", "list");
var ids = new List<int>();
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;
}
/// <summary>
/// Exports a Transport bundle scoped to a single template via
/// <c>bundle export</c>.
///
/// <para>
/// The CLI's <c>bundle export</c> scopes templates <em>by name</em>
/// (<c>--templates &lt;comma-separated names&gt;</c>) — there is no id-based
/// selector — so this resolves the template's name from
/// <paramref name="templateId"/> via <c>template list</c> and passes that
/// single name. Exactly one matching template must exist.
/// </para>
/// </summary>
/// <param name="outputPath">Destination <c>.scadabundle</c> path.</param>
/// <param name="templateId">Id of the single template to export.</param>
/// <param name="passphrase">Encryption passphrase for the bundle.</param>
/// <param name="sourceEnvironment">
/// <c>SourceEnvironment</c> value stamped into the bundle manifest.
/// </param>
/// <exception cref="InvalidOperationException">
/// The template id could not be resolved to a name, or the CLI failed.
/// </exception>
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);
}
/// <summary>
/// Resolves a template's name from its id via <c>template list</c>.
/// </summary>
/// <exception cref="InvalidOperationException">No template matched the id.</exception>
private static async Task<string> 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'.");
}
/// <summary>
/// Runs an <c>instance &lt;verb&gt; --id &lt;id&gt;</c> command, surfacing CLI
/// failures to the caller.
/// </summary>
private static async Task RunInstanceVerbAsync(string verb, int id)
{
await RunAsync(
"instance", verb,
"--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
/// <summary>
/// Runs a best-effort delete-style command, swallowing any failure so teardown
/// in a <c>finally</c> never masks the test's own outcome.
/// </summary>
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.
}
}
/// <summary>
/// Extracts a required integer <c>id</c> from a create-command response,
/// throwing a descriptive error if it is missing or non-integral.
/// </summary>
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()}");
}
}