using Microsoft.Playwright; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; using Xunit; 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 — 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 /// ), 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 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")] public class DeploymentActionTests : IClassFixture { 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); } } /// /// Navigates to the Topology page, expands the tree so the instance row under /// site-a → zztest area is rendered, locates the row by its /// label, and opens its right-click context menu. /// Returns the located row so callers can re-target it if needed. /// /// /// 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. /// /// private static async Task 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; } }