diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index 1b6debf..285f4f6 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -428,6 +428,9 @@ global value in v1; per-channel overrides are deferred to v1.x. hosts the Audit Log page (filter bar, results grid, drilldown drawer, server-side CSV export). Drill-in links appear on Notifications, Site Calls, External Systems, Inbound API key, Sites, and Instances detail pages. + Double-clicking a node on the execution-tree page opens a detail modal + listing that execution's audit rows, with click-through to each row's full + detail view. - **[Health Monitoring (#11)](Component-HealthMonitoring.md)** — three new tiles (Volume, Error rate, Backlog) plus new health metrics: `SiteAuditBacklog`, `SiteAuditWriteFailures`, `SiteAuditTelemetryStalled`, diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs index d3e77c8..7391fbf 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs @@ -516,6 +516,110 @@ public class AuditLogPageTests } } + [Fact] + public async Task DoubleClickTreeNode_OpensExecutionRowModal() + { + // Execution-Tree Node Detail Modal feature, Task 5: double-clicking a + // node on the /audit/execution-tree page opens ExecutionDetailModal — + // a modal listing that execution's audit rows, with click-through to + // each row's full view. We seed ONE execution with + // TWO audit rows (so the modal opens to the list view, not straight to + // a single-row detail), open the tree, double-click the node, walk + // list → row → detail, then close the modal. + if (!await AuditDataSeeder.IsAvailableAsync()) + { + throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions."); + } + + var runId = Guid.NewGuid().ToString("N"); + var targetPrefix = $"playwright-test/exec-node-modal/{runId}/"; + var executionId = Guid.NewGuid(); + var inboundEventId = Guid.NewGuid(); + var outboundEventId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + // Two rows sharing the same ExecutionId — an inbound request and an + // outbound call it made. The shared ExecutionId makes the tree node + // multi-row, so the modal lands on the list view. + await AuditDataSeeder.InsertAuditEventAsync( + eventId: inboundEventId, + occurredAtUtc: now, + channel: "ApiInbound", + kind: "InboundRequest", + status: "Delivered", + target: targetPrefix + "inbound", + executionId: executionId, + httpStatus: 200, + durationMs: 9); + + await AuditDataSeeder.InsertAuditEventAsync( + eventId: outboundEventId, + occurredAtUtc: now, + channel: "ApiOutbound", + kind: "ApiCall", + status: "Delivered", + target: targetPrefix + "outbound", + executionId: executionId, + httpStatus: 200, + durationMs: 21); + + var page = await _fixture.NewAuthenticatedPageAsync(); + + // Open the execution tree directly for the seeded execution. + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/execution-tree?executionId={executionId}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The seeded execution renders as a tree node. + var nodeBody = page.Locator($"[data-test='tree-node-{executionId}'] .execution-tree-body"); + await Assertions.Expect(nodeBody).ToBeVisibleAsync(); + + // Double-clicking the node body raises ExecutionTree's @ondblclick, + // which is a Blazor Server (InteractiveServer) handler — it only + // fires once the SignalR circuit is live. NetworkIdle can settle + // before the circuit connects, so a single early DblClick can be + // dropped. Retry the double-click until the modal appears. + var modal = page.Locator("[data-test='execution-detail-modal']"); + for (var attempt = 0; attempt < 10 && await modal.CountAsync() == 0; attempt++) + { + await nodeBody.DblClickAsync(); + try + { + await modal.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 1000 }); + } + catch (TimeoutException) + { + // Circuit not connected yet — loop and re-issue the dblclick. + } + } + + await Assertions.Expect(modal).ToBeVisibleAsync(); + + // The modal opens on the list view — one button per audit row. + var inboundRow = page.Locator($"[data-test='execution-detail-row-{inboundEventId}']"); + var outboundRow = page.Locator($"[data-test='execution-detail-row-{outboundEventId}']"); + await Assertions.Expect(inboundRow).ToBeVisibleAsync(); + await Assertions.Expect(outboundRow).ToBeVisibleAsync(); + + // Clicking a row switches the modal to that row's full detail — + // the shared field block renders. + await outboundRow.ClickAsync(); + await Assertions.Expect(page.Locator("[data-test='drawer-fields']")).ToBeVisibleAsync(); + + // Closing the modal tears it down. The close click round-trips + // over the SignalR circuit before the @if(IsOpen) block re-renders + // away, so use the auto-retrying assertion rather than a bare + // CountAsync. + await page.Locator("[data-test='execution-detail-close']").ClickAsync(); + await Assertions.Expect(modal).ToHaveCountAsync(0); + } + finally + { + await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + [Fact] public async Task NotificationsPage_RendersAuditDrillInLinkPattern() {