using Microsoft.Playwright; using Xunit; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring; /// /// End-to-end render + control-gating tests for the Site Event Logs page /// (/monitoring/event-logs). /// /// /// Why this is a render + controls + tolerant-query suite (no row assertions): /// Site event logs live in the SITE's local SQLite store and are fetched on demand via /// an Akka Ask round-trip to a live site — there is no central table, and no CLI /// or DB seed path. A clean site may legitimately have zero logged events, so these /// facts must never assert on specific event rows. Instead they verify the two things /// that are deterministic regardless of site state: /// /// The Search button is gated on site selection (disabled until a /// site is chosen, then enabled). /// Clicking Search resolves the query — the results table renders /// either a data row or the empty-state row. /// /// The row-expand affordance is exercised only when real data rows happen to exist. /// /// /// /// Gated on via Skip.IfNot: when the cluster is /// unreachable the facts report as Skipped (not Failed), matching the suite idiom. /// /// [Collection("Playwright")] public class EventLogsTests { private const string EventLogsUrl = "/monitoring/event-logs"; private readonly PlaywrightFixture _fixture; public EventLogsTests(PlaywrightFixture fixture) { _fixture = fixture; } /// /// The Search button must be disabled on page load (no site selected) and become /// enabled once a site is selected from the site dropdown. Deterministic regardless /// of whether the site has any logged events. /// [SkippableFact] public async Task EventLogs_SearchGatedOnSiteSelection() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); var page = await _fixture.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{EventLogsUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Assertions.Expect(page.Locator("h4:has-text('Site Event Logs')")).ToBeVisibleAsync(); // The site selector is populated from the cluster's known sites; the live cluster // exposes site-a. Each option's value is the site's SiteIdentifier. await Assertions.Expect(page.Locator("#filter-site option[value='site-a']")).ToHaveCountAsync(1); // Search is gated: disabled until a site is selected. var search = page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')"); await Assertions.Expect(search).ToBeDisabledAsync(); await page.Locator("#filter-site").SelectOptionAsync(new SelectOptionValue { Value = "site-a" }); await Assertions.Expect(search).ToBeEnabledAsync(new() { Timeout = 5_000 }); } /// /// After selecting a site and clicking Search, the query must resolve: the results /// table renders either a data row or the empty-state row (both match /// table tbody tr). The row-expand affordance is exercised only when real data /// rows exist — a site may have zero logged events, and event logs are not seedable /// from central, so the empty-state render is itself the assertion in that case. /// [SkippableFact] public async Task EventLogs_Search_RendersTableOrEmptyState() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); var page = await _fixture.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{EventLogsUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Assertions.Expect(page.Locator("h4:has-text('Site Event Logs')")).ToBeVisibleAsync(); await page.Locator("#filter-site").SelectOptionAsync(new SelectOptionValue { Value = "site-a" }); var searchBtn = page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')"); await searchBtn.ClickAsync(); // The Search button re-enables only when _searching flips back to false, i.e. after the // site's Akka Ask round-trip completes. Gating on it here makes the table assertion below a // genuine post-query check (a hung Ask fails here instead of false-passing on the // intermediate empty render Blazor flushes while _entries is a momentary empty list). await Assertions.Expect(searchBtn).ToBeEnabledAsync(new() { Timeout = 15_000 }); // The query has completed (Search re-enabled above), so the terminal table render is now // present: a data row OR the empty-state row. The empty-state markup is itself a // No events found. living inside table tbody, so "table tbody // tr" matches it whether or not the site has events. var settled = page.Locator("table tbody tr"); await Assertions.Expect(settled.First).ToBeVisibleAsync(new() { Timeout = 5_000 }); // Exercise the row-expand ONLY IF real data rows exist. A clean site may have zero // logged events, and event logs are not seedable from central — so when no expand // toggle is present, the empty-state render above is the assertion (tolerated). var expandBtns = page.Locator("button[aria-label='View full message']"); if (await expandBtns.CountAsync() > 0) { await expandBtns.First.ClickAsync(); await Assertions.Expect(page.Locator("button[aria-label='Hide full message']").First) .ToBeVisibleAsync(new() { Timeout = 5_000 }); } // else: no events logged on this site — the empty-state render is the assertion. } }