test(e2e): add CliRunner typed fixture helpers
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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-<kind>-<8 hex></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>
|
||||
/// <exception cref="InvalidOperationException">The CLI failed.</exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
/// 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>
|
||||
/// 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 <comma-separated names></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);
|
||||
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 <verb> --id <id></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()}");
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
/// assembly to the repo root and probes the Debug/Release output paths.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CliRunner
|
||||
public static partial class CliRunner
|
||||
{
|
||||
/// <summary>Hard timeout for a single CLI invocation.</summary>
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
|
||||
/// <summary>
|
||||
/// TDD coverage for the typed <see cref="CliRunner"/> 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.
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class CliRunnerHelpersTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A freshly created template is discoverable by name prefix and can be
|
||||
/// deleted, exercising <see cref="CliRunner.CreateTemplateAsync"/>,
|
||||
/// <see cref="CliRunner.ListTemplateIdsByNamePrefixAsync"/>, and
|
||||
/// <see cref="CliRunner.DeleteTemplateAsync"/> as a round-trip.
|
||||
/// </summary>
|
||||
[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); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="CliRunner.ResolveSiteIdAsync"/> finds the well-known
|
||||
/// <c>site-a</c> seed site by its <c>siteIdentifier</c> and returns a
|
||||
/// positive id.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task ResolveSiteA_ReturnsId()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
Assert.True(await CliRunner.ResolveSiteIdAsync("site-a") > 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user