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.
This commit is contained in:
Joseph Doherty
2026-05-21 09:22:50 -04:00
parent 40955bbca6
commit d34f536220
2 changed files with 117 additions and 18 deletions

View File

@@ -83,6 +83,36 @@ public class AuditGridColumnTests
.EvaluateAllAsync<string[]>("els => els.map(e => e.getAttribute('data-col-key'))");
}
/// <summary>
/// Polls until <paramref name="storageKey"/> has been written to
/// <c>sessionStorage</c>. The grid persists a resize/reorder
/// asynchronously — the browser-side drag fires a fire-and-forget
/// JS→.NET invoke (<c>OnColumnResized</c>/<c>OnColumnReordered</c>), and
/// the .NET handler then round-trips back through JS interop to write
/// <c>sessionStorage</c>. A bare <c>getItem</c> immediately after the drag
/// races that round-trip; this waits for the key to actually land.
/// </summary>
private static async Task WaitForStorageKeyAsync(IPage page, string storageKey)
{
await page.WaitForFunctionAsync(
"key => sessionStorage.getItem(key) !== null", storageKey);
}
/// <summary>
/// Polls until the header's first column key equals <paramref name="expectedFirstKey"/>.
/// A drag-to-reorder re-renders the header asynchronously (the JS→.NET
/// <c>OnColumnReordered</c> invoke is fire-and-forget), so reading the
/// header order synchronously after <c>DragToAsync</c> can observe the
/// pre-reorder layout. This waits for the re-render to settle.
/// </summary>
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<string?>(
"() => sessionStorage.getItem('auditGrid:columnOrder')");
var widthsJson = await page.EvaluateAsync<string?>(
@@ -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]);

View File

@@ -51,6 +51,40 @@ public class SiteCallsPageTests
_fixture = fixture;
}
/// <summary>
/// Sets the Target-keyword search box and commits the value to the server
/// as its own discrete circuit message before the caller clicks Query.
/// <para>
/// The <c>#sc-search</c> input is a Blazor <c>@bind</c>
/// (commit-on-<c>change</c>): <see cref="ILocator.FillAsync"/> only fires
/// <c>input</c> events, and the <c>change</c> that actually updates
/// <c>_targetFilter</c> on the server fires on blur. The original test
/// relied on the Query <c>ClickAsync</c> itself to blur the field — that
/// makes the <c>change</c> (blur) and the <c>click</c> a single, near-
/// simultaneous gesture and races them over the SignalR circuit: when the
/// <c>click</c> is processed before the <c>change</c> has updated
/// <c>_targetFilter</c>, <c>Search()</c> runs with a stale (empty) keyword
/// and the grid returns unfiltered rows.
/// </para>
/// <para>
/// <see cref="ILocator.DispatchEventAsync"/> raises the <c>change</c> as a
/// fully-awaited action of its own, so its circuit message is enqueued and
/// sent before the later Query <c>ClickAsync</c>'s message. The SignalR
/// connection delivers messages in send order and the Blazor circuit
/// processes them sequentially, so <c>_targetFilter</c> is guaranteed
/// committed before <c>Search()</c> runs — the two are no longer one
/// racing gesture.
/// </para>
/// </summary>
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