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})."); // 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( "() => 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); } } }