test(e2e): add DeploymentFixture (ephemeral instance on site-a)
This commit is contained in:
+206
@@ -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<DeploymentFixture></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);
|
||||
}
|
||||
Reference in New Issue
Block a user