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