using Microsoft.Playwright; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring; /// /// End-to-end guard for the Health dashboard KPI tiles (/monitoring/health). /// /// /// The Health dashboard fans out to three Akka cluster-singleton Asks every /// 10 s (Notification Outbox, Site Call, Audit) to populate nine KPI tiles. A /// previously-fixed bug caused those Asks to hang, leaving every tile showing /// the em-dash degrade placeholder () instead of a resolved numeric value. /// This test guards that regression: it asserts that every tile resolves to a value /// and never shows . /// /// /// /// The three tile groups and their selectors: /// /// /// Notification Outbox — inlined in Health.razor with no /// data-test attribute. Each is a Bootstrap div.card whose /// .card-body contains a value h3 and a small.text-muted /// label. Located by the card that contains the label text, then its h3. /// /// /// Audit — rendered by AuditKpiTiles.razor; each tile button /// carries a data-test attribute: audit-kpi-volume, /// audit-kpi-error-rate, audit-kpi-backlog. /// /// /// Site Calls — rendered by SiteCallKpiTiles.razor; each tile /// button carries a data-test attribute: site-call-kpi-buffered, /// site-call-kpi-stuck, site-call-kpi-parked. /// /// /// /// [Collection("Playwright")] public class HealthDashboardTests { private const string HealthUrl = "/monitoring/health"; /// /// The degrade placeholder rendered when a KPI loader faults — an em-dash /// (U+2014). A healthy tile shows a non-negative integer instead. /// private const string DegradePlaceholder = "—"; // — private readonly PlaywrightFixture _fixture; public HealthDashboardTests(PlaywrightFixture fixture) { _fixture = fixture; } /// /// Asserts that all nine KPI tiles on the Health dashboard resolve to numeric /// values and do not show the em-dash degrade placeholder (). /// /// /// A generous 20 s per-tile timeout is intentional: the tiles are populated /// asynchronously after initial render as the three singleton Asks /// complete. The Playwright web-first assertion retries within that window /// rather than using a fixed sleep, so a fast cluster will pass quickly. /// /// [SkippableFact] public async Task KpiTiles_ResolveToValues_NotDegradePlaceholder() { Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); var page = await _fixture.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{HealthUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // ── Notification Outbox tiles (no data-test; inlined in Health.razor as // plain div.card elements under the "Notification Outbox" h6 heading). // Scope all three cards to the div.row that immediately follows the flex // container holding the h6 — prevents false matches if a second section // later grows cards with the same label text ("Stuck", "Parked"). // // Health.razor structure: //
//
Notification Outbox
← anchor // View details → //
//
← outboxSection (+sibling) //
Queue Depth
//
Stuck
//
Parked
//
var outboxSection = page.Locator("div.d-flex:has(h6:has-text('Notification Outbox')) + div.row"); var queueDepthH3 = outboxSection.Locator("div.card", new() { HasText = "Queue Depth" }).Locator("h3"); var outboxStuckH3 = outboxSection.Locator("div.card", new() { HasText = "Stuck" }).Locator("h3"); var outboxParkedH3 = outboxSection.Locator("div.card", new() { HasText = "Parked" }).Locator("h3"); await AssertTileResolvedAsync(queueDepthH3, "Outbox Queue Depth"); await AssertTileResolvedAsync(outboxStuckH3, "Outbox Stuck"); await AssertTileResolvedAsync(outboxParkedH3, "Outbox Parked"); // ── Audit KPI tiles (AuditKpiTiles.razor — data-test on the button) ── var auditVolume = page.Locator("[data-test='audit-kpi-volume']").Locator("h3"); var auditErrorRate = page.Locator("[data-test='audit-kpi-error-rate']").Locator("h3"); var auditBacklog = page.Locator("[data-test='audit-kpi-backlog']").Locator("h3"); await AssertTileResolvedAsync(auditVolume, "Audit Volume"); await AssertTileResolvedAsync(auditErrorRate, "Audit Error Rate"); await AssertTileResolvedAsync(auditBacklog, "Audit Backlog"); // ── Site Call KPI tiles (SiteCallKpiTiles.razor — data-test on the button) ── var siteCallBuffered = page.Locator("[data-test='site-call-kpi-buffered']").Locator("h3"); var siteCallStuck = page.Locator("[data-test='site-call-kpi-stuck']").Locator("h3"); var siteCallParked = page.Locator("[data-test='site-call-kpi-parked']").Locator("h3"); await AssertTileResolvedAsync(siteCallBuffered, "Site Call Buffered"); await AssertTileResolvedAsync(siteCallStuck, "Site Call Stuck"); await AssertTileResolvedAsync(siteCallParked, "Site Call Parked"); } /// /// Waits up to 20 s for to be non-empty and not /// equal to the em-dash degrade placeholder. Uses Playwright web-first /// assertions so the retry loop is inside the Playwright engine, not a /// C# busy-wait. /// private static async Task AssertTileResolvedAsync(ILocator tileH3, string tileName) { // The tile must be visible and contain text before we check its value. await Assertions.Expect(tileH3) .ToBeVisibleAsync(new() { Timeout = 20_000 }); // Primary guard: the value must NOT be the degrade placeholder. await Assertions.Expect(tileH3) .Not.ToHaveTextAsync(DegradePlaceholder, new() { Timeout = 20_000 }); // Secondary guard: the value must be non-empty — it should be a digit string. await Assertions.Expect(tileH3) .Not.ToBeEmptyAsync(new() { Timeout = 20_000 }); // Diagnostic: capture the resolved text for context in test output, but // don't fail on any particular number (the cluster state is environment- // dependent and any non-negative integer is valid). var resolvedText = await tileH3.TextContentAsync(); Assert.True( resolvedText != null && resolvedText.Trim().Length > 0, $"KPI tile '{tileName}' resolved to null/empty content."); Assert.False( resolvedText!.Trim() == DegradePlaceholder, $"KPI tile '{tileName}' shows the degrade placeholder '—' — singleton Ask likely hung or faulted."); } }