ResizeHandle_DraggingWidensColumn_AndSurvivesReload called page.ReloadAsync() immediately after the resize drag, racing the asynchronous persist: pointer-up fires a fire-and-forget JS→.NET OnColumnResized invoke that round-trips back through JS interop to write sessionStorage. When the reload won the race the restored grid fell back to the default column width and the test failed (~1 in 3 runs). Wait for auditGrid:columnWidths to land via the existing WaitForStorageKeyAsync helper before reloading — the same guard the sibling ColumnOrderAndWidths_PersistAcrossReload_ViaSessionStorage test already uses. Verified: 6/6 consecutive passes.
290 lines
13 KiB
C#
290 lines
13 KiB
C#
using Microsoft.Playwright;
|
|
using Xunit;
|
|
|
|
namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
|
|
|
/// <summary>
|
|
/// End-to-end coverage for the Audit Log results-grid column UX (#23
|
|
/// follow-ups Task 10): drag-to-resize and drag-to-reorder columns, with the
|
|
/// chosen widths + order persisted in the browser's <c>sessionStorage</c>.
|
|
///
|
|
/// <para>
|
|
/// The drag interaction is browser-side (<c>wwwroot/js/audit-grid.js</c>), so
|
|
/// Playwright — not bUnit — is the right tool: bUnit cannot drive the native
|
|
/// HTML5 drag-and-drop or pointer-capture resize. Each test seeds one
|
|
/// <c>AuditLog</c> row via <see cref="AuditDataSeeder"/> so the grid has a
|
|
/// header row to act on, then best-effort deletes it.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
|
|
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
|
|
/// matching the established <see cref="SiteCalls.SiteCallsPageTests"/> idiom.
|
|
/// </para>
|
|
/// </summary>
|
|
[Collection("Playwright")]
|
|
public class AuditGridColumnTests
|
|
{
|
|
private const string AuditLogUrl = "/audit/log";
|
|
|
|
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
|
|
private const string DbUnavailableSkipReason =
|
|
"AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
|
|
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.";
|
|
|
|
private readonly PlaywrightFixture _fixture;
|
|
|
|
public AuditGridColumnTests(PlaywrightFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds one audit row, opens the Audit Log page, and clicks Apply so the
|
|
/// results grid renders a header row the column tests can act on.
|
|
/// </summary>
|
|
private async Task<IPage> OpenGridWithSeededRowAsync(string targetPrefix, Guid eventId)
|
|
{
|
|
await AuditDataSeeder.InsertAuditEventAsync(
|
|
eventId: eventId,
|
|
occurredAtUtc: DateTime.UtcNow,
|
|
channel: "ApiOutbound",
|
|
kind: "ApiCall",
|
|
status: "Delivered",
|
|
target: targetPrefix + "endpoint",
|
|
httpStatus: 200,
|
|
durationMs: 25);
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{AuditLogUrl}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Apply with no chips — the default LastHour range matches the fresh row.
|
|
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
var row = page.Locator($"[data-test='grid-row-{eventId}']");
|
|
await Assertions.Expect(row).ToBeVisibleAsync();
|
|
return page;
|
|
}
|
|
|
|
/// <summary>Pixel width of a header cell, measured from its bounding box.</summary>
|
|
private static async Task<double> HeaderWidthAsync(IPage page, string columnKey)
|
|
{
|
|
var box = await page.Locator($"[data-col-key='{columnKey}']").BoundingBoxAsync();
|
|
Assert.NotNull(box);
|
|
return box!.Width;
|
|
}
|
|
|
|
/// <summary>The ordered list of column keys as currently rendered in the header.</summary>
|
|
private static async Task<IReadOnlyList<string>> HeaderOrderAsync(IPage page)
|
|
{
|
|
return await page.Locator("thead th[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]
|
|
public async Task ResizeHandle_DraggingWidensColumn_AndSurvivesReload()
|
|
{
|
|
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var targetPrefix = $"playwright-test/grid-resize/{runId}/";
|
|
var eventId = Guid.NewGuid();
|
|
|
|
try
|
|
{
|
|
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
|
|
|
|
const string columnKey = "Target";
|
|
var before = await HeaderWidthAsync(page, columnKey);
|
|
|
|
// Drag the resize handle on the column's right edge 120px to the
|
|
// right. The handle is a thin strip; grab its centre and drag.
|
|
var handle = page.Locator($"[data-test='col-resize-{columnKey}']");
|
|
var handleBox = await handle.BoundingBoxAsync();
|
|
Assert.NotNull(handleBox);
|
|
var startX = handleBox!.X + handleBox.Width / 2;
|
|
var startY = handleBox.Y + handleBox.Height / 2;
|
|
|
|
await page.Mouse.MoveAsync(startX, startY);
|
|
await page.Mouse.DownAsync();
|
|
await page.Mouse.MoveAsync(startX + 120, startY, new MouseMoveOptions { Steps = 8 });
|
|
await page.Mouse.UpAsync();
|
|
|
|
var after = await HeaderWidthAsync(page, columnKey);
|
|
Assert.True(after > before + 40,
|
|
$"Expected the {columnKey} column to widen after the resize drag (before={before}, after={after}).");
|
|
|
|
// The resize persists asynchronously: pointer-up fires a
|
|
// fire-and-forget JS→.NET OnColumnResized invoke, and the .NET
|
|
// handler then round-trips back through JS interop to write
|
|
// sessionStorage. Wait for that write to land before reloading —
|
|
// otherwise the reload races it and the restored grid falls back
|
|
// to the default width.
|
|
await WaitForStorageKeyAsync(page, "auditGrid:columnWidths");
|
|
|
|
// Reload: the persisted width is restored from sessionStorage.
|
|
await page.ReloadAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
await page.Locator("[data-test='filter-apply']").ClickAsync();
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
var afterReload = await HeaderWidthAsync(page, columnKey);
|
|
// Allow a small tolerance for sub-pixel layout rounding.
|
|
Assert.True(Math.Abs(afterReload - after) < 8,
|
|
$"Expected the resized width to survive a reload (after={after}, afterReload={afterReload}).");
|
|
}
|
|
finally
|
|
{
|
|
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task ReorderDrag_MovesColumn_AndSurvivesReload()
|
|
{
|
|
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var targetPrefix = $"playwright-test/grid-reorder/{runId}/";
|
|
var eventId = Guid.NewGuid();
|
|
|
|
try
|
|
{
|
|
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
|
|
|
|
var initialOrder = await HeaderOrderAsync(page);
|
|
// Default order opens with OccurredAtUtc first, Status fifth.
|
|
Assert.Equal("OccurredAtUtc", initialOrder[0]);
|
|
Assert.Contains("Status", initialOrder);
|
|
|
|
// Drag the Status header onto the OccurredAtUtc header — Status
|
|
// should move into the leading slot.
|
|
var source = page.Locator("[data-col-key='Status']");
|
|
var target = page.Locator("[data-col-key='OccurredAtUtc']");
|
|
await source.DragToAsync(target);
|
|
// 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 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]);
|
|
}
|
|
finally
|
|
{
|
|
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task ColumnOrderAndWidths_PersistAcrossReload_ViaSessionStorage()
|
|
{
|
|
Skip.IfNot(await AuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
|
|
|
var runId = Guid.NewGuid().ToString("N");
|
|
var targetPrefix = $"playwright-test/grid-persist/{runId}/";
|
|
var eventId = Guid.NewGuid();
|
|
|
|
try
|
|
{
|
|
var page = await OpenGridWithSeededRowAsync(targetPrefix, eventId);
|
|
|
|
// Reorder then resize, then confirm sessionStorage carries both.
|
|
await page.Locator("[data-col-key='Status']")
|
|
.DragToAsync(page.Locator("[data-col-key='OccurredAtUtc']"));
|
|
// 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();
|
|
Assert.NotNull(handleBox);
|
|
var startX = handleBox!.X + handleBox.Width / 2;
|
|
var startY = handleBox.Y + handleBox.Height / 2;
|
|
await page.Mouse.MoveAsync(startX, startY);
|
|
await page.Mouse.DownAsync();
|
|
await page.Mouse.MoveAsync(startX + 90, startY, new MouseMoveOptions { Steps = 6 });
|
|
await page.Mouse.UpAsync();
|
|
|
|
// 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?>(
|
|
"() => sessionStorage.getItem('auditGrid:columnWidths')");
|
|
Assert.NotNull(orderJson);
|
|
Assert.Contains("Status", orderJson!);
|
|
Assert.NotNull(widthsJson);
|
|
Assert.Contains("Target", widthsJson!);
|
|
|
|
// 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]);
|
|
}
|
|
finally
|
|
{
|
|
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
|
}
|
|
}
|
|
}
|