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); + } +}