From d34f536220844265156a5585f8343ea6f7cb4f83 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 09:22:50 -0400 Subject: [PATCH] fix(centralui): stabilise Site Calls + Audit grid Playwright E2E Three Playwright E2E failures, all test-side timing/data bugs (no feature defects found): - AuditGridColumnTests.ColumnOrderAndWidths_PersistAcrossReload: read sessionStorage synchronously right after Mouse.UpAsync, racing the async OnColumnResized/OnColumnReordered JS->.NET->JS save round-trip. Now polls (WaitForFunctionAsync) for the storage keys and for the reorder re-render to settle; also hardens the flaky ReorderDrag test. - SiteCallsPageTests.FilterNarrowing_ChannelFilterShrinksGrid: the Target-keyword #sc-search @bind committed via the Query click's own blur, racing change vs click on the circuit so Search() sometimes ran with a stale empty filter. Commit the value with an explicit, fully-awaited DispatchEventAsync('change') and use the retrying ToHaveCount assertion for the negative row checks. - SiteCallsPageTests.RetryClickThrough_OnParkedRow: seeded SourceSite 'plant-a' is not a real cluster site (site-a/b/c), so the relay had no ClusterClient route and only resolved on the 10s inner Ask timeout - past the 5s toast wait. Seed a live site (site-a) for a fast NotParked round-trip and give the toast a 15s wait. Playwright E2E suite: 60 passed, 0 failed, 0 skipped. --- .../Audit/AuditGridColumnTests.cs | 58 ++++++++++++-- .../SiteCalls/SiteCallsPageTests.cs | 77 +++++++++++++++---- 2 files changed, 117 insertions(+), 18 deletions(-) diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs index e6fb771..36a9dba 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditGridColumnTests.cs @@ -83,6 +83,36 @@ public class AuditGridColumnTests .EvaluateAllAsync("els => els.map(e => e.getAttribute('data-col-key'))"); } + /// + /// Polls until has been written to + /// sessionStorage. The grid persists a resize/reorder + /// asynchronously — the browser-side drag fires a fire-and-forget + /// JS→.NET invoke (OnColumnResized/OnColumnReordered), and + /// the .NET handler then round-trips back through JS interop to write + /// sessionStorage. A bare getItem immediately after the drag + /// races that round-trip; this waits for the key to actually land. + /// + private static async Task WaitForStorageKeyAsync(IPage page, string storageKey) + { + await page.WaitForFunctionAsync( + "key => sessionStorage.getItem(key) !== null", storageKey); + } + + /// + /// Polls until the header's first column key equals . + /// A drag-to-reorder re-renders the header asynchronously (the JS→.NET + /// OnColumnReordered invoke is fire-and-forget), so reading the + /// header order synchronously after DragToAsync can observe the + /// pre-reorder layout. This waits for the re-render to settle. + /// + private static async Task WaitForFirstColumnAsync(IPage page, string expectedFirstKey) + { + await page.WaitForFunctionAsync( + "key => { var th = document.querySelector('thead th[data-col-key]'); " + + "return th && th.getAttribute('data-col-key') === key; }", + expectedFirstKey); + } + [SkippableFact] public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload() { @@ -156,18 +186,22 @@ public class AuditGridColumnTests var source = page.Locator("[data-col-key='Status']"); var target = page.Locator("[data-col-key='OccurredAtUtc']"); await source.DragToAsync(target); - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + // The reorder re-renders the header asynchronously (fire-and-forget + // JS→.NET invoke); wait for it to settle before reading the order. + await WaitForFirstColumnAsync(page, "Status"); var afterOrder = await HeaderOrderAsync(page); Assert.Equal("Status", afterOrder[0]); Assert.True(afterOrder.ToList().IndexOf("Status") < afterOrder.ToList().IndexOf("OccurredAtUtc"), "Expected Status to be reordered ahead of OccurredAtUtc."); - // Reload: the persisted order is restored from sessionStorage. + // Reload: the persisted order is restored from sessionStorage on + // the grid's first render — wait for the header to reflect it. await page.ReloadAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await page.Locator("[data-test='filter-apply']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await WaitForFirstColumnAsync(page, "Status"); var afterReload = await HeaderOrderAsync(page); Assert.Equal("Status", afterReload[0]); @@ -194,7 +228,10 @@ public class AuditGridColumnTests // Reorder then resize, then confirm sessionStorage carries both. await page.Locator("[data-col-key='Status']") .DragToAsync(page.Locator("[data-col-key='OccurredAtUtc']")); - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + // Wait for the reorder re-render to settle before measuring the + // resize handle, so the handle's bounding box is read off the + // post-reorder layout. + await WaitForFirstColumnAsync(page, "Status"); var handle = page.Locator("[data-test='col-resize-Target']"); var handleBox = await handle.BoundingBoxAsync(); @@ -206,7 +243,15 @@ public class AuditGridColumnTests await page.Mouse.MoveAsync(startX + 90, startY, new MouseMoveOptions { Steps = 6 }); await page.Mouse.UpAsync(); - // Both keys are written under the auditGrid: namespace. + // Both keys are written under the auditGrid: namespace — but the + // write is asynchronous: pointer-up fires a fire-and-forget + // OnColumnResized/OnColumnReordered JS→.NET invoke, and the .NET + // handler then round-trips back through JS interop to call + // auditGrid.save. Reading sessionStorage synchronously right after + // Mouse.UpAsync races that round-trip, so poll for both keys to + // land before asserting on them. + await WaitForStorageKeyAsync(page, "auditGrid:columnOrder"); + await WaitForStorageKeyAsync(page, "auditGrid:columnWidths"); var orderJson = await page.EvaluateAsync( "() => sessionStorage.getItem('auditGrid:columnOrder')"); var widthsJson = await page.EvaluateAsync( @@ -216,11 +261,14 @@ public class AuditGridColumnTests Assert.NotNull(widthsJson); Assert.Contains("Target", widthsJson!); - // After a reload the restored grid reflects the stored order. + // After a reload the restored grid reflects the stored order. The + // restore happens on the grid's first render (LoadPersistedState → + // StateHasChanged), so wait for the header to reflect it. await page.ReloadAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await page.Locator("[data-test='filter-apply']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await WaitForFirstColumnAsync(page, "Status"); var restoredOrder = await HeaderOrderAsync(page); Assert.Equal("Status", restoredOrder[0]); diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs index 242c197..303aa90 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs @@ -51,6 +51,40 @@ public class SiteCallsPageTests _fixture = fixture; } + /// + /// Sets the Target-keyword search box and commits the value to the server + /// as its own discrete circuit message before the caller clicks Query. + /// + /// The #sc-search input is a Blazor @bind + /// (commit-on-change): only fires + /// input events, and the change that actually updates + /// _targetFilter on the server fires on blur. The original test + /// relied on the Query ClickAsync itself to blur the field — that + /// makes the change (blur) and the click a single, near- + /// simultaneous gesture and races them over the SignalR circuit: when the + /// click is processed before the change has updated + /// _targetFilter, Search() runs with a stale (empty) keyword + /// and the grid returns unfiltered rows. + /// + /// + /// raises the change as a + /// fully-awaited action of its own, so its circuit message is enqueued and + /// sent before the later Query ClickAsync's message. The SignalR + /// connection delivers messages in send order and the Blazor circuit + /// processes them sequentially, so _targetFilter is guaranteed + /// committed before Search() runs — the two are no longer one + /// racing gesture. + /// + /// + private static async Task SetSearchKeywordAsync(IPage page, string keyword) + { + var search = page.Locator("#sc-search"); + await search.FillAsync(keyword); + // Commit the @bind as a discrete change event — not a blur side effect + // of the subsequent Query click. + await search.DispatchEventAsync("change"); + } + [Fact] public async Task PageLoads_ForDeploymentUser() { @@ -98,22 +132,26 @@ public class SiteCallsPageTests // Unfiltered query: both seeded rows appear (the Target keyword scopes // to this run so unrelated cluster rows do not interfere). - await page.Locator("#sc-search").FillAsync(targetPrefix + "api"); + await SetSearchKeywordAsync(page, targetPrefix + "api"); await page.Locator("[data-test='site-calls-query']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - // Only the ApiOutbound row matches the exact target keyword. + // Only the ApiOutbound row matches the exact target keyword. The + // grid filters with an exact Target match, so the db row must be + // absent — use the retrying ToHaveCount assertion so the negative + // check waits out the post-query re-render rather than reading a + // point-in-time count. await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync(); - Assert.Equal(0, await page.Locator($"text={targetPrefix}db").CountAsync()); + await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToHaveCountAsync(0); // Now filter by Channel = DbOutbound with the db target — the row flips. - await page.Locator("#sc-search").FillAsync(targetPrefix + "db"); + await SetSearchKeywordAsync(page, targetPrefix + "db"); await page.Locator("#sc-channel").SelectOptionAsync("DbOutbound"); await page.Locator("[data-test='site-calls-query']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync(); - Assert.Equal(0, await page.Locator($"text={targetPrefix}api").CountAsync()); + await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToHaveCountAsync(0); } finally { @@ -142,7 +180,7 @@ public class SiteCallsPageTests await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - await page.Locator("#sc-search").FillAsync(targetPrefix + "endpoint"); + await SetSearchKeywordAsync(page, targetPrefix + "endpoint"); await page.Locator("[data-test='site-calls-query']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); @@ -199,7 +237,7 @@ public class SiteCallsPageTests await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // Query the parked row first. - await page.Locator("#sc-search").FillAsync(targetPrefix + "parked"); + await SetSearchKeywordAsync(page, targetPrefix + "parked"); await page.Locator("[data-test='site-calls-query']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); @@ -210,7 +248,7 @@ public class SiteCallsPageTests await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync(); // Now the Failed row — Retry/Discard are absent. - await page.Locator("#sc-search").FillAsync(targetPrefix + "failed"); + await SetSearchKeywordAsync(page, targetPrefix + "failed"); await page.Locator("[data-test='site-calls-query']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); @@ -238,10 +276,18 @@ public class SiteCallsPageTests try { // A single Parked row — the only status from which Retry/Discard can - // be relayed to the owning site. + // be relayed to the owning site. Unlike the display-only tests above, + // this one actually relays to the owning site, so the SourceSite must + // be a *real* site identifier from the running cluster (site-a) and + // not the cosmetic "plant-a" label: an unknown site has no registered + // ClusterClient, so CentralCommunicationActor drops the envelope + // without replying and the relay only resolves on the 10s inner Ask + // timeout — too slow for the toast assertion below. Relayed to a live + // site, the site finds no parked S&F message for this freshly-seeded + // GUID and replies a fast NotParked ack, which still surfaces a toast. await SiteCallDataSeeder.InsertSiteCallAsync( trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked", - sourceSite: "plant-a", status: "Parked", retryCount: 3, + sourceSite: "site-a", status: "Parked", retryCount: 3, lastError: "HTTP 503 from ERP", httpStatus: 503, createdAtUtc: now, updatedAtUtc: now); @@ -249,7 +295,7 @@ public class SiteCallsPageTests await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - await page.Locator("#sc-search").FillAsync(targetPrefix + "parked"); + await SetSearchKeywordAsync(page, targetPrefix + "parked"); await page.Locator("[data-test='site-calls-query']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); @@ -269,9 +315,14 @@ public class SiteCallsPageTests // the owning site is offline in this environment, SiteUnreachable. // We only assert that an outcome toast appears (exactly one — the // single-toast contract), not which one, since the live cluster - // state determines the outcome. + // state determines the outcome. The wait is generous (15s): the + // relay round-trips to the site over ClusterClient, and a worst-case + // path can sit on the 10s inner relay timeout before the response — + // and the toast itself auto-dismisses 5s after it appears, so the + // assertion must catch it inside that window. var toast = page.Locator(".toast"); - await Assertions.Expect(toast).ToBeVisibleAsync(); + await Assertions.Expect(toast).ToBeVisibleAsync( + new() { Timeout = 15_000 }); Assert.Equal(1, await toast.CountAsync()); } finally