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()}"); } }