using Microsoft.Playwright;
using Xunit;
namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
///
/// 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 sessionStorage.
///
///
/// The drag interaction is browser-side (wwwroot/js/audit-grid.js), 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
/// AuditLog row via so the grid has a
/// header row to act on, then best-effort deletes it.
///
///
///
/// The DB-seeding tests are + Skip.IfNot:
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
/// matching the established idiom.
///
///
[Collection("Playwright")]
public class AuditGridColumnTests
{
private const string AuditLogUrl = "/audit/log";
/// Skip reason shared by the DB-seeding tests when MSSQL is down.
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;
}
///
/// 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.
///
private async Task 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;
}
/// Pixel width of a header cell, measured from its bounding box.
private static async Task HeaderWidthAsync(IPage page, string columnKey)
{
var box = await page.Locator($"[data-col-key='{columnKey}']").BoundingBoxAsync();
Assert.NotNull(box);
return box!.Width;
}
/// The ordered list of column keys as currently rendered in the header.
private static async Task> HeaderOrderAsync(IPage page)
{
return await page.Locator("thead th[data-col-key]")
.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()
{
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}).");
// 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(
"() => sessionStorage.getItem('auditGrid:columnOrder')");
var widthsJson = await page.EvaluateAsync(
"() => 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);
}
}
}