From 234ddb5201898706381acce9079565fd8baeab63 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 5 Jun 2026 10:25:06 -0400 Subject: [PATCH] test(e2e): add DeploymentFixture (ephemeral instance on site-a) --- .../Deployment/DeploymentFixture.cs | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs new file mode 100644 index 00000000..7c25a16d --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentFixture.cs @@ -0,0 +1,206 @@ +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; + } + + 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")); + } + + /// + /// 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; + 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. + } + + await CliRunner.DeleteAreaAsync(AreaId); + await CliRunner.DeleteTemplateAsync(TemplateId); + } + + /// + /// 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); +}