From 348c01c91ab9eff0de0cbd2de7169e7de9512b9d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 13:25:53 -0400 Subject: [PATCH] test(e2e): Deployments page pushes deploy rows via SignalR; pause suppresses, Refresh restores --- .../Deployment/DeploymentsRealtimeTests.cs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentsRealtimeTests.cs diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentsRealtimeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentsRealtimeTests.cs new file mode 100644 index 00000000..65cd5e8b --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/DeploymentsRealtimeTests.cs @@ -0,0 +1,111 @@ +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 Deployment Status page's real-time, push-based update +/// model (Tier 2). The page (/deployment/deployments) is NOT polled: in +/// OnInitializedAsync it subscribes to +/// IDeploymentStatusNotifier.StatusChanged — a process singleton the +/// DeploymentManager raises on every deployment-record status write — and reloads +/// the table on each notification, which Blazor Server pushes to the browser over +/// its SignalR circuit. So with the page already open, a CLI instance deploy +/// makes the instance's row surface with no manual Refresh: the row simply +/// appearing IS the proof the push path works. +/// +/// +/// Each fact mints a fresh ephemeral instance on the real site-a (via +/// ) and deletes it in a +/// finally. The minted uniqueName is unique, so its row can never +/// pre-exist — the row's appearance is attributable solely to the deploy under test. +/// +/// +/// +/// Why Fact B's negative is deterministic: the Pause button toggles +/// _autoRefresh, and OnDeploymentStatusChanged early-returns on +/// !_autoRefresh, so a paused page deterministically ignores the +/// StatusChanged notification. The pause click round-trips over the SignalR +/// circuit (and we wait for the button to flip to "Resume" before deploying), so +/// _autoRefresh is committed false server-side BEFORE the deploy fires +/// — the suppression is not a race. Refresh calls LoadDataAsync directly, +/// bypassing the pause guard, so the row surfaces on the explicit reload. +/// +/// +[Collection("Playwright")] +public class DeploymentsRealtimeTests : IClassFixture +{ + private readonly PlaywrightFixture _pw; + private readonly DeploymentFixture _cluster; + + public DeploymentsRealtimeTests(PlaywrightFixture pw, DeploymentFixture cluster) + { + _pw = pw; + _cluster = cluster; + } + + [SkippableFact] + public async Task DeployingInstance_PushesRowWithoutManualRefresh() + { + Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason); + + var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync(); + try + { + // Open the page FIRST so the StatusChanged subscription is live before we deploy. + var page = await _pw.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/deployments"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await Assertions.Expect(page.Locator("h4:has-text('Deployment Status')")).ToBeVisibleAsync(); + + // Deploy over the CLI — this writes deployment records and raises StatusChanged, + // which the page reloads on (push). No Refresh click here: the row appearing IS + // the proof the SignalR push path works. + await CliRunner.DeployInstanceAsync(instanceId); + + var row = page.Locator("table tbody tr", new() { HasText = uniqueName }); + await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 25_000 }); + } + finally + { + await CliRunner.DeleteInstanceAsync(instanceId); + } + } + + [SkippableFact] + public async Task PausedUpdates_SuppressPush_RefreshRestoresRow() + { + Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason); + + var (instanceId, uniqueName) = await _cluster.CreateInstanceAsync(); + try + { + var page = await _pw.NewAuthenticatedPageAsync(); + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/deployment/deployments"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // Pause — the click round-trips over the circuit, so _autoRefresh is committed + // false before the deploy below. The button flipping to "Resume" proves it. + await page.Locator("button[aria-label='Pause auto-refresh']").ClickAsync(); + await Assertions.Expect(page.Locator("button[aria-label='Resume auto-refresh']")) + .ToBeVisibleAsync(new() { Timeout = 5_000 }); + + await CliRunner.DeployInstanceAsync(instanceId); + + // Paused: StatusChanged is ignored, so the row is NOT auto-added. Settle briefly to + // give any (erroneous) push time to manifest, then assert absence. + var row = page.Locator("table tbody tr", new() { HasText = uniqueName }); + await page.WaitForTimeoutAsync(2_000); + await Assertions.Expect(row).ToHaveCountAsync(0); + + // Refresh bypasses the pause (LoadDataAsync) -> the row surfaces. + await page.Locator("button[aria-label='Refresh deployments']").ClickAsync(); + await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 }); + } + finally + { + await CliRunner.DeleteInstanceAsync(instanceId); + } + } +}