diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentActionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentActionTests.cs
index 502fe3de..af2479e6 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentActionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentActionTests.cs
@@ -6,19 +6,33 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
///
/// E2E coverage for the per-instance deploy-family actions exposed from the
-/// Topology tree's right-click context menu. This is the first of the
-/// deploy-action tests; the helper
-/// (navigate → expand → locate the instance row → right-click) is shared so the
-/// Enable/Disable/Delete tests can extend this file without re-deriving the tree
-/// navigation.
+/// Topology tree's right-click context menu — Deploy, Disable, Enable, and
+/// Delete. The helper (navigate →
+/// expand → locate the instance row → right-click) is shared across every fact so
+/// none has to re-derive the tree navigation.
///
///
/// Each fact mints a fresh ephemeral instance on the real site-a (via
-/// ) so it carries the
-/// expected Deploy (not Redeploy) menu state, exercises the action
+/// ), exercises the action
/// against the live cluster, then deletes the instance in a finally.
-/// Outcomes are tolerant: the relay to site-a may return a deployed
-/// confirmation or a fast error, but either way a single outcome toast appears.
+/// Outcomes are tolerant: the relay to site-a 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 ToHaveCountAsync(1) (it retries
+/// atomically, so it neither flakes on the relay round-trip nor races the toast's
+/// ~5s auto-dismiss).
+///
+///
+///
+/// The context menu offered per instance depends on the instance's config state
+/// (verified against Topology.razor): Deploy/Redeploy and
+/// Delete are always present; Disable is shown only when the state
+/// is Enabled, and Enable only when Disabled. So the
+/// Disable/Enable facts drive the precondition state deterministically over the
+/// CLI (deploy → Enabled; deploy then disable → Disabled)
+/// before opening the menu. Deploy and Enable fire with no confirm
+/// dialog; Disable and Delete are danger actions that first raise a
+/// confirm modal whose confirm button is .modal-footer .btn-danger (labelled
+/// Delete).
///
///
[Collection("Playwright")]
@@ -53,11 +67,12 @@ public class DeploymentActionTests : IClassFixture
// 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 assert promptly with a generous
- // wait for the relay round-trip.
+ // 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).ToBeVisibleAsync(new() { Timeout = 15_000 });
- Assert.Equal(1, await toast.CountAsync());
+ await Assertions.Expect(toast).ToHaveCountAsync(1, new() { Timeout = 15_000 });
}
finally
{
@@ -65,6 +80,100 @@ public class DeploymentActionTests : IClassFixture
}
}
+ [SkippableFact]
+ public async Task Disable_Instance_ShowsOutcomeToast()
+ {
+ Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
+
+ var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync();
+ // 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);
+ try
+ {
+ 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();
+ // Deploy then Disable leaves the instance config state at Disabled, so the
+ // context menu offers "Enable".
+ await CliRunner.DeployInstanceAsync(instanceId);
+ await CliRunner.DisableInstanceAsync(instanceId);
+ try
+ {
+ 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);
+ }
+ }
+
///
/// Navigates to the Topology page, expands the tree so the instance row under
/// site-a → zztest area is rendered, locates the row by its