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