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); // Confirm the page loaded before interacting (symmetric with Fact A) — a failed // load then surfaces as a clear heading-miss rather than a confusing Pause timeout. await Assertions.Expect(page.Locator("h4:has-text('Deployment Status')")).ToBeVisibleAsync(); // 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. The push chain // (deploy write → StatusChanged → InvokeAsync re-render → SignalR diff) completes // well under a second on a healthy cluster, so a 2s settle is ample to let any // erroneous push manifest before we assert absence. This is the one deliberate // fixed wait — a negative real-time assertion has no DOM event to await on the // "stayed absent" path. The Refresh-restores assertion below is the independent // positive guard if a loaded cluster ever makes this settle empirically tight. 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); } } }