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

@@ -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