5546c32593
In Disable_Instance_ShowsOutcomeToast and Enable_Instance_ShowsOutcomeToast, the precondition CLI calls (DeployInstanceAsync / DisableInstanceAsync) were between CreateInstanceAsync and the try block. A throw there would skip the finally DeleteInstanceAsync, leaking the instance. Moved those calls to be the first statements inside try so cleanup always runs once the instance exists.
219 lines
9.6 KiB
C#
219 lines
9.6 KiB
C#
using Microsoft.Playwright;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
|
|
|
/// <summary>
|
|
/// E2E coverage for the per-instance deploy-family actions exposed from the
|
|
/// Topology tree's right-click context menu — Deploy, Disable, Enable, and
|
|
/// Delete. The <see cref="OpenInstanceContextMenuAsync"/> helper (navigate →
|
|
/// expand → locate the instance row → right-click) is shared across every fact so
|
|
/// none has to re-derive the tree navigation.
|
|
///
|
|
/// <para>
|
|
/// Each fact mints a fresh ephemeral instance on the real <c>site-a</c> (via
|
|
/// <see cref="DeploymentFixture.CreateInstanceAsync"/>), exercises the action
|
|
/// against the live cluster, then deletes the instance in a <c>finally</c>.
|
|
/// Outcomes are tolerant: the relay to <c>site-a</c> may return a confirmation or
|
|
/// a fast error, but either way a single outcome toast appears, which every fact
|
|
/// asserts with a single web-first <c>ToHaveCountAsync(1)</c> (it retries
|
|
/// atomically, so it neither flakes on the relay round-trip nor races the toast's
|
|
/// ~5s auto-dismiss).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The context menu offered per instance depends on the instance's config state
|
|
/// (verified against <c>Topology.razor</c>): <c>Deploy</c>/<c>Redeploy</c> and
|
|
/// <c>Delete</c> are always present; <c>Disable</c> is shown only when the state
|
|
/// is <c>Enabled</c>, and <c>Enable</c> only when <c>Disabled</c>. So the
|
|
/// Disable/Enable facts drive the precondition state deterministically over the
|
|
/// CLI (<c>deploy</c> → Enabled; <c>deploy</c> then <c>disable</c> → Disabled)
|
|
/// before opening the menu. <c>Deploy</c> and <c>Enable</c> fire with no confirm
|
|
/// dialog; <c>Disable</c> and <c>Delete</c> are danger actions that first raise a
|
|
/// confirm modal whose confirm button is <c>.modal-footer .btn-danger</c> (labelled
|
|
/// <c>Delete</c>).
|
|
/// </para>
|
|
/// </summary>
|
|
[Collection("Playwright")]
|
|
public class DeploymentActionTests : IClassFixture<DeploymentFixture>
|
|
{
|
|
private readonly PlaywrightFixture _pw;
|
|
private readonly DeploymentFixture _cluster;
|
|
|
|
public DeploymentActionTests(PlaywrightFixture pw, DeploymentFixture cluster)
|
|
{
|
|
_pw = pw;
|
|
_cluster = cluster;
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Deploy_Instance_ShowsOutcomeToast()
|
|
{
|
|
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
|
|
|
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
|
try
|
|
{
|
|
var page = await _pw.NewAuthenticatedPageAsync();
|
|
await OpenInstanceContextMenuAsync(page, uniqueName);
|
|
|
|
// A fresh instance is NotDeployed, so the action reads "Deploy" (it would
|
|
// read "Redeploy" only for a stale, already-deployed node). Deploy has no
|
|
// confirm dialog — clicking relays to site-a immediately.
|
|
await page.Locator(".dropdown-menu.show button.dropdown-item", new() { HasText = "Deploy" })
|
|
.ClickAsync();
|
|
|
|
// The relay outcome surfaces on a toast — deployed confirmation or a fast
|
|
// error. We assert exactly one toast (the single-toast contract), not which
|
|
// outcome, since the live cluster may answer either way. The toast
|
|
// auto-dismisses ~5s after it appears, so a separate ToBeVisible + CountAsync
|
|
// would race that dismissal (a TOCTOU between the two reads); ToHaveCountAsync
|
|
// retries atomically against the live DOM, with a generous wait for the relay
|
|
// round-trip.
|
|
var toast = page.Locator(".toast");
|
|
await Assertions.Expect(toast).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
|
}
|
|
finally
|
|
{
|
|
await CliRunner.DeleteInstanceAsync(instanceId);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Disable_Instance_ShowsOutcomeToast()
|
|
{
|
|
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
|
|
|
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
|
try
|
|
{
|
|
// Deploy moves the instance config state to Enabled, so the context menu
|
|
// offers "Disable" (it would offer "Enable" only for a Disabled instance).
|
|
await CliRunner.DeployInstanceAsync(instanceId);
|
|
var page = await _pw.NewAuthenticatedPageAsync();
|
|
await OpenInstanceContextMenuAsync(page, uniqueName);
|
|
|
|
await page.Locator(".dropdown-menu.show button.dropdown-item", new() { HasText = "Disable" })
|
|
.ClickAsync();
|
|
|
|
// Disable is a danger action: it raises a confirm modal first. Confirm via
|
|
// the danger button (labelled "Delete" for every danger confirm).
|
|
await page.Locator(".modal-footer .btn-danger", new() { HasText = "Delete" })
|
|
.ClickAsync();
|
|
|
|
// One outcome toast (confirmation or fast error). ToHaveCountAsync retries
|
|
// atomically, so it survives the relay round-trip and the toast's ~5s dismiss.
|
|
await Assertions.Expect(page.Locator(".toast"))
|
|
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
|
}
|
|
finally
|
|
{
|
|
await CliRunner.DeleteInstanceAsync(instanceId);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Enable_Instance_ShowsOutcomeToast()
|
|
{
|
|
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
|
|
|
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
|
try
|
|
{
|
|
// Deploy then Disable leaves the instance config state at Disabled, so the
|
|
// context menu offers "Enable".
|
|
await CliRunner.DeployInstanceAsync(instanceId);
|
|
await CliRunner.DisableInstanceAsync(instanceId);
|
|
var page = await _pw.NewAuthenticatedPageAsync();
|
|
await OpenInstanceContextMenuAsync(page, uniqueName);
|
|
|
|
// Enable fires immediately — no confirm dialog.
|
|
await page.Locator(".dropdown-menu.show button.dropdown-item", new() { HasText = "Enable" })
|
|
.ClickAsync();
|
|
|
|
await Assertions.Expect(page.Locator(".toast"))
|
|
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
|
}
|
|
finally
|
|
{
|
|
await CliRunner.DeleteInstanceAsync(instanceId);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task Delete_Instance_RemovesFromTree()
|
|
{
|
|
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
|
|
|
var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
|
|
try
|
|
{
|
|
var page = await _pw.NewAuthenticatedPageAsync();
|
|
await OpenInstanceContextMenuAsync(page, uniqueName);
|
|
|
|
// Delete is always present (independent of state) and is the danger item.
|
|
await page.Locator(".dropdown-menu.show button.dropdown-item.text-danger")
|
|
.ClickAsync();
|
|
|
|
// Danger action: confirm via the modal's danger button.
|
|
await page.Locator(".modal-footer .btn-danger", new() { HasText = "Delete" })
|
|
.ClickAsync();
|
|
|
|
// The delete succeeds locally (NotDeployed instance) and the tree reloads,
|
|
// so the row for this instance disappears. ToHaveCountAsync(0) retries until
|
|
// the reload settles.
|
|
await Assertions.Expect(page.Locator("div.tv-row", new() { HasText = uniqueName }))
|
|
.ToHaveCountAsync(0, new() { Timeout = 15_000 });
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort net: if the UI delete failed, this removes the leftover
|
|
// instance; if it already deleted, this is a no-op (delete is best-effort).
|
|
await CliRunner.DeleteInstanceAsync(instanceId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Navigates to the Topology page, expands the tree so the instance row under
|
|
/// <c>site-a → zztest area</c> is rendered, locates the row by its
|
|
/// <paramref name="uniqueName"/> label, and opens its right-click context menu.
|
|
/// Returns the located row so callers can re-target it if needed.
|
|
///
|
|
/// <para>
|
|
/// Live updates are switched off first: the page reloads the tree on a 15s timer,
|
|
/// which would collapse a freshly-expanded node and tear down an open context
|
|
/// menu mid-interaction. With it off the tree stays stable through the action.
|
|
/// </para>
|
|
/// </summary>
|
|
private static async Task<ILocator> OpenInstanceContextMenuAsync(IPage page, string uniqueName)
|
|
{
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/topology");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Stop the 15s live-updates timer from rebuilding the tree under us.
|
|
var liveToggle = page.Locator("#live-updates");
|
|
if (await liveToggle.IsCheckedAsync())
|
|
{
|
|
await liveToggle.UncheckAsync();
|
|
}
|
|
|
|
// ExpandAll() recurses every branch (site → area → …), so one click reveals
|
|
// the nested instance row under site-a's zztest area.
|
|
await page.Locator("button[aria-label='Expand all areas']").ClickAsync();
|
|
|
|
var row = page.Locator("div.tv-row", new() { HasText = uniqueName });
|
|
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
|
|
|
// The tree scrolls inside a fixed-height container; bring the row into view
|
|
// so the right-click lands on it and the menu renders at a usable position.
|
|
await row.ScrollIntoViewIfNeededAsync();
|
|
await row.ClickAsync(new() { Button = MouseButton.Right });
|
|
|
|
await Assertions.Expect(page.Locator(".dropdown-menu.show"))
|
|
.ToBeVisibleAsync(new() { Timeout = 5_000 });
|
|
|
|
return row;
|
|
}
|
|
}
|