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