test(e2e): add DeploymentFixture (ephemeral instance on site-a)

This commit is contained in:
Joseph Doherty
2026-06-05 10:25:06 -04:00
parent 3d9ef0a477
commit 234ddb5201
@@ -0,0 +1,206 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
/// <summary>
/// <see cref="IAsyncLifetime"/> fixture that provisions the shared, ephemeral
/// scaffolding (a template + an area on <c>site-a</c>) the deploy-action E2E test
/// class consumes via <c>IClassFixture&lt;DeploymentFixture&gt;</c>, and mints
/// per-test instances on demand via <see cref="CreateInstanceAsync"/>.
///
/// <para>
/// <b>Why <c>site-a</c> and not a throwaway site:</b> the deploy / enable / disable
/// actions relay to the owning site over the Akka <c>ClusterClient</c>. An unknown
/// site identifier has no registered <c>ClusterClient</c>, 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 <em>real, running</em> site — <c>site-a</c> — rather than a fixture-created
/// throwaway site.
/// </para>
///
/// <para>
/// Provisioning is gated on <see cref="ClusterAvailability.IsAvailableAsync"/>:
/// when the dev cluster is down, <see cref="InitializeAsync"/> sets
/// <see cref="Available"/> to <see langword="false"/> and returns early without
/// touching the cluster, so the consuming tests can skip (via <c>Skip.IfNot</c>)
/// and teardown becomes a no-op.
/// </para>
///
/// <para>
/// Cleanup is best-effort and swallows every error: <see cref="DisposeAsync"/>
/// lists the instances on <c>site-a</c>, deletes any whose name/unique name carries
/// the <c>zztest-inst-</c> fixture prefix, then deletes the fixture's area and
/// template. The <c>zztest-</c> prefix marks entities as test-owned so teardown
/// never touches cluster-owned data.
/// </para>
/// </summary>
public sealed class DeploymentFixture : IAsyncLifetime
{
private const string SiteAIdentifier = "site-a";
/// <summary>
/// Fixture-name prefix for the per-test instances minted by
/// <see cref="CreateInstanceAsync"/>; teardown deletes <c>site-a</c> instances
/// carrying this prefix.
/// </summary>
private const string InstanceNamePrefix = "zztest-inst-";
/// <summary>Numeric id of the real, running <c>site-a</c> the fixture provisions onto.</summary>
public int SiteAId { get; private set; }
/// <summary>Id of the ephemeral template instances are created from.</summary>
public int TemplateId { get; private set; }
/// <summary>Id of the ephemeral area on <c>site-a</c> instances are placed under.</summary>
public int AreaId { get; private set; }
/// <summary>
/// Whether the dev cluster was reachable at setup. When <see langword="false"/>,
/// nothing was provisioned: consuming tests should skip and teardown is a no-op.
/// </summary>
public bool Available { get; private set; }
/// <summary>
/// Probes cluster availability and, when reachable, provisions the shared
/// scaffolding: resolves <c>site-a</c>'s id, creates an ephemeral template with a
/// single <c>Double</c> <c>Value</c> attribute (so the template validates and is
/// deployable), and creates an ephemeral area on <c>site-a</c>. When the cluster
/// is unavailable, sets <see cref="Available"/> to <see langword="false"/> and
/// returns without touching the cluster.
/// </summary>
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"));
}
/// <summary>
/// Creates a fresh ephemeral instance from the fixture's template, on
/// <c>site-a</c>, under the fixture's area, and returns both its numeric
/// <c>id</c> and its server-assigned <c>uniqueName</c>.
///
/// <para>
/// The <c>uniqueName</c> is read straight off the <c>instance create</c> response
/// because it can differ from the <c>--name</c> passed in (the server may
/// area-/path-qualify it). The Topology tree renders this <c>uniqueName</c> in
/// <c>span.tv-label</c>, so deploy tests must locate the instance row by the
/// returned <c>uniqueName</c>, not by the name they requested.
/// </para>
/// </summary>
/// <returns>The new instance's <c>id</c> and server-assigned <c>uniqueName</c>.</returns>
/// <exception cref="InvalidOperationException">
/// The CLI failed, or the response did not carry an integer <c>id</c> and a
/// string <c>uniqueName</c>.
/// </exception>
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);
}
/// <summary>
/// Best-effort teardown: when the fixture provisioned anything, lists the
/// instances on <c>site-a</c> and deletes any whose name/unique name starts with
/// <see cref="InstanceNamePrefix"/>, then deletes the fixture's area and
/// template. Swallows every error so a teardown hiccup never fails the suite.
/// </summary>
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);
}
/// <summary>
/// Lists <c>site-a</c> via <c>instance list</c> and returns the ids of every
/// instance whose <c>name</c> or <c>uniqueName</c> starts with
/// <see cref="InstanceNamePrefix"/> — the fixture-owned instances to delete.
/// </summary>
private async Task<IReadOnlyList<int>> ListFixtureInstanceIdsAsync()
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
using var doc = await CliRunner.RunJsonAsync(
"instance", "list",
"--site-id", SiteAId.ToString(inv));
var ids = new List<int>();
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;
}
/// <summary>
/// Returns whether an instance JSON object's <c>name</c> or <c>uniqueName</c>
/// carries the fixture's <see cref="InstanceNamePrefix"/>.
/// </summary>
private static bool HasFixturePrefix(JsonElement instance) =>
StartsWithPrefix(instance, "name") || StartsWithPrefix(instance, "uniqueName");
/// <summary>
/// Returns whether the named string property of <paramref name="instance"/>
/// starts with <see cref="InstanceNamePrefix"/> (ordinal comparison).
/// </summary>
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);
}