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:
@@ -83,6 +83,36 @@ public class AuditGridColumnTests
|
|||||||
.EvaluateAllAsync<string[]>("els => els.map(e => e.getAttribute('data-col-key'))");
|
.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]
|
[SkippableFact]
|
||||||
public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload()
|
public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload()
|
||||||
{
|
{
|
||||||
@@ -156,18 +186,22 @@ public class AuditGridColumnTests
|
|||||||
var source = page.Locator("[data-col-key='Status']");
|
var source = page.Locator("[data-col-key='Status']");
|
||||||
var target = page.Locator("[data-col-key='OccurredAtUtc']");
|
var target = page.Locator("[data-col-key='OccurredAtUtc']");
|
||||||
await source.DragToAsync(target);
|
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);
|
var afterOrder = await HeaderOrderAsync(page);
|
||||||
Assert.Equal("Status", afterOrder[0]);
|
Assert.Equal("Status", afterOrder[0]);
|
||||||
Assert.True(afterOrder.ToList().IndexOf("Status") < afterOrder.ToList().IndexOf("OccurredAtUtc"),
|
Assert.True(afterOrder.ToList().IndexOf("Status") < afterOrder.ToList().IndexOf("OccurredAtUtc"),
|
||||||
"Expected Status to be reordered ahead of 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.ReloadAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
await WaitForFirstColumnAsync(page, "Status");
|
||||||
|
|
||||||
var afterReload = await HeaderOrderAsync(page);
|
var afterReload = await HeaderOrderAsync(page);
|
||||||
Assert.Equal("Status", afterReload[0]);
|
Assert.Equal("Status", afterReload[0]);
|
||||||
@@ -194,7 +228,10 @@ public class AuditGridColumnTests
|
|||||||
// Reorder then resize, then confirm sessionStorage carries both.
|
// Reorder then resize, then confirm sessionStorage carries both.
|
||||||
await page.Locator("[data-col-key='Status']")
|
await page.Locator("[data-col-key='Status']")
|
||||||
.DragToAsync(page.Locator("[data-col-key='OccurredAtUtc']"));
|
.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 handle = page.Locator("[data-test='col-resize-Target']");
|
||||||
var handleBox = await handle.BoundingBoxAsync();
|
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.MoveAsync(startX + 90, startY, new MouseMoveOptions { Steps = 6 });
|
||||||
await page.Mouse.UpAsync();
|
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?>(
|
var orderJson = await page.EvaluateAsync<string?>(
|
||||||
"() => sessionStorage.getItem('auditGrid:columnOrder')");
|
"() => sessionStorage.getItem('auditGrid:columnOrder')");
|
||||||
var widthsJson = await page.EvaluateAsync<string?>(
|
var widthsJson = await page.EvaluateAsync<string?>(
|
||||||
@@ -216,11 +261,14 @@ public class AuditGridColumnTests
|
|||||||
Assert.NotNull(widthsJson);
|
Assert.NotNull(widthsJson);
|
||||||
Assert.Contains("Target", 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.ReloadAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
await WaitForFirstColumnAsync(page, "Status");
|
||||||
|
|
||||||
var restoredOrder = await HeaderOrderAsync(page);
|
var restoredOrder = await HeaderOrderAsync(page);
|
||||||
Assert.Equal("Status", restoredOrder[0]);
|
Assert.Equal("Status", restoredOrder[0]);
|
||||||
|
|||||||
@@ -51,6 +51,40 @@ public class SiteCallsPageTests
|
|||||||
_fixture = fixture;
|
_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]
|
[Fact]
|
||||||
public async Task PageLoads_ForDeploymentUser()
|
public async Task PageLoads_ForDeploymentUser()
|
||||||
{
|
{
|
||||||
@@ -98,22 +132,26 @@ public class SiteCallsPageTests
|
|||||||
|
|
||||||
// Unfiltered query: both seeded rows appear (the Target keyword scopes
|
// Unfiltered query: both seeded rows appear (the Target keyword scopes
|
||||||
// to this run so unrelated cluster rows do not interfere).
|
// 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.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
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();
|
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.
|
// 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("#sc-channel").SelectOptionAsync("DbOutbound");
|
||||||
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
await page.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync();
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -142,7 +180,7 @@ public class SiteCallsPageTests
|
|||||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
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.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
@@ -199,7 +237,7 @@ public class SiteCallsPageTests
|
|||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
// Query the parked row first.
|
// 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.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
@@ -210,7 +248,7 @@ public class SiteCallsPageTests
|
|||||||
await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync();
|
await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync();
|
||||||
|
|
||||||
// Now the Failed row — Retry/Discard are absent.
|
// 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.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
@@ -238,10 +276,18 @@ public class SiteCallsPageTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// A single Parked row — the only status from which Retry/Discard can
|
// 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(
|
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||||
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
|
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,
|
lastError: "HTTP 503 from ERP", httpStatus: 503,
|
||||||
createdAtUtc: now, updatedAtUtc: now);
|
createdAtUtc: now, updatedAtUtc: now);
|
||||||
|
|
||||||
@@ -249,7 +295,7 @@ public class SiteCallsPageTests
|
|||||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
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.Locator("[data-test='site-calls-query']").ClickAsync();
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
@@ -269,9 +315,14 @@ public class SiteCallsPageTests
|
|||||||
// the owning site is offline in this environment, SiteUnreachable.
|
// the owning site is offline in this environment, SiteUnreachable.
|
||||||
// We only assert that an outcome toast appears (exactly one — the
|
// We only assert that an outcome toast appears (exactly one — the
|
||||||
// single-toast contract), not which one, since the live cluster
|
// 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");
|
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());
|
Assert.Equal(1, await toast.CountAsync());
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
Reference in New Issue
Block a user