using System.Text.Json; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment; /// /// fixture that provisions the shared, ephemeral /// scaffolding (a template + an area on site-a) the deploy-action E2E test /// class consumes via IClassFixture<DeploymentFixture>, and mints /// per-test instances on demand via . /// /// /// Why site-a and not a throwaway site: the deploy / enable / disable /// actions relay to the owning site over the Akka ClusterClient. An unknown /// site identifier has no registered ClusterClient, so the relay only /// resolves on a slow 10-second timeout (and never surfaces a fast failure toast). /// To exercise the real action path, the ephemeral instances must therefore live /// on a real, running site — site-a — rather than a fixture-created /// throwaway site. /// /// /// /// Provisioning is gated on : /// when the dev cluster is down, sets /// to and returns early without /// touching the cluster, so the consuming tests can skip (via Skip.IfNot) /// and teardown becomes a no-op. /// /// /// /// Cleanup is best-effort and swallows every error: /// lists the instances on site-a, deletes any whose name/unique name carries /// the zztest-inst- fixture prefix, then deletes the fixture's area and /// template. The zztest- prefix marks entities as test-owned so teardown /// never touches cluster-owned data. /// /// public sealed class DeploymentFixture : IAsyncLifetime { private const string SiteAIdentifier = "site-a"; /// /// Fixture-name prefix for the per-test instances minted by /// ; teardown deletes site-a instances /// carrying this prefix. /// private const string InstanceNamePrefix = "zztest-inst-"; /// Numeric id of the real, running site-a the fixture provisions onto. public int SiteAId { get; private set; } /// Id of the ephemeral template instances are created from. public int TemplateId { get; private set; } /// Id of the ephemeral area on site-a instances are placed under. public int AreaId { get; private set; } /// /// Whether the dev cluster was reachable at setup. When , /// nothing was provisioned: consuming tests should skip and teardown is a no-op. /// public bool Available { get; private set; } /// /// Probes cluster availability and, when reachable, provisions the shared /// scaffolding: resolves site-a's id, creates an ephemeral template with a /// single Double Value attribute (so the template validates and is /// deployable), and creates an ephemeral area on site-a. When the cluster /// is unavailable, sets to and /// returns without touching the cluster. /// public async Task InitializeAsync() { Available = await ClusterAvailability.IsAvailableAsync(); if (!Available) { return; } try { SiteAId = await CliRunner.ResolveSiteIdAsync(SiteAIdentifier); TemplateId = await CliRunner.CreateTemplateAsync(CliRunner.UniqueName("deploytmpl")); await CliRunner.AddAttributeAsync(TemplateId, "Value", "Double"); AreaId = await CliRunner.CreateAreaAsync(SiteAId, CliRunner.UniqueName("area")); } catch { // Partial-init guard: best-effort cleanup of whatever was created before // the failure; DeleteAreaAsync/DeleteTemplateAsync are no-ops for id 0. await CliRunner.DeleteAreaAsync(AreaId); await CliRunner.DeleteTemplateAsync(TemplateId); Available = false; throw; } } /// /// Creates a fresh ephemeral instance from the fixture's template, on /// site-a, under the fixture's area, and returns both its numeric /// id and its server-assigned uniqueName. /// /// /// The uniqueName is read straight off the instance create response /// because it can differ from the --name passed in (the server may /// area-/path-qualify it). The Topology tree renders this uniqueName in /// span.tv-label, so deploy tests must locate the instance row by the /// returned uniqueName, not by the name they requested. /// /// /// The new instance's id and server-assigned uniqueName. /// /// The CLI failed, or the response did not carry an integer id and a /// string uniqueName. /// public async Task<(int Id, string UniqueName)> CreateInstanceAsync() { var inv = System.Globalization.CultureInfo.InvariantCulture; // "inst" must match the InstanceNamePrefix ("zztest-inst-") used by the teardown sweep. var name = CliRunner.UniqueName("inst"); using var doc = await CliRunner.RunJsonAsync( "instance", "create", "--name", name, "--template-id", TemplateId.ToString(inv), "--site-id", SiteAId.ToString(inv), "--area-id", AreaId.ToString(inv)); var root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("id", out var idElement) || !idElement.TryGetInt32(out var id) || !root.TryGetProperty("uniqueName", out var uniqueNameElement) || uniqueNameElement.ValueKind != JsonValueKind.String || uniqueNameElement.GetString() is not { } uniqueName) { throw new InvalidOperationException( "'instance create' response did not contain an integer 'id' and a string " + $"'uniqueName': {root.GetRawText()}"); } return (id, uniqueName); } /// /// Best-effort teardown: when the fixture provisioned anything, lists the /// instances on site-a and deletes any whose name/unique name starts with /// , then deletes the fixture's area and /// template. Swallows every error so a teardown hiccup never fails the suite. /// public async Task DisposeAsync() { if (!Available) { return; } try { foreach (var instanceId in await ListFixtureInstanceIdsAsync()) { await CliRunner.DeleteInstanceAsync(instanceId); } } catch { // Best-effort teardown — never fail the suite on a cleanup hiccup. } try { await CliRunner.DeleteAreaAsync(AreaId); } catch { } try { await CliRunner.DeleteTemplateAsync(TemplateId); } catch { } } /// /// Lists site-a via instance list and returns the ids of every /// instance whose name or uniqueName starts with /// — the fixture-owned instances to delete. /// private async Task> ListFixtureInstanceIdsAsync() { var inv = System.Globalization.CultureInfo.InvariantCulture; using var doc = await CliRunner.RunJsonAsync( "instance", "list", "--site-id", SiteAId.ToString(inv)); var ids = new List(); if (doc.RootElement.ValueKind == JsonValueKind.Array) { foreach (var instance in doc.RootElement.EnumerateArray()) { if (instance.ValueKind == JsonValueKind.Object && HasFixturePrefix(instance) && instance.TryGetProperty("id", out var id) && id.TryGetInt32(out var instanceId)) { ids.Add(instanceId); } } } return ids; } /// /// Returns whether an instance JSON object's name or uniqueName /// carries the fixture's . /// private static bool HasFixturePrefix(JsonElement instance) => StartsWithPrefix(instance, "name") || StartsWithPrefix(instance, "uniqueName"); /// /// Returns whether the named string property of /// starts with (ordinal comparison). /// private static bool StartsWithPrefix(JsonElement instance, string property) => instance.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String && (value.GetString()?.StartsWith(InstanceNamePrefix, StringComparison.Ordinal) ?? false); }