diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationKpisTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationKpisTests.cs
new file mode 100644
index 00000000..b9ee0455
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationKpisTests.cs
@@ -0,0 +1,79 @@
+using Microsoft.Playwright;
+using Xunit;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
+
+///
+/// End-to-end coverage for the Notification KPIs page (/notifications/kpis).
+///
+///
+/// This page is pure-read (no mutations, no fixture seeding, no teardown). It requires
+/// the Deployment role; the test user multi-role has it. The KPI values themselves
+/// are non-deterministic; these tests assert structural render only — either the 5 KPI
+/// tiles render (happy path) or the cluster-unavailable alert renders (degraded path),
+/// and the Refresh button completes a round-trip without hanging.
+///
+///
+[Collection("Playwright")]
+public class NotificationKpisTests
+{
+ private const string KpisUrl = "/notifications/kpis";
+
+ private readonly PlaywrightFixture _pw;
+
+ public NotificationKpisTests(PlaywrightFixture pw)
+ {
+ _pw = pw;
+ }
+
+ ///
+ /// Navigates to the Notification KPIs page and asserts that the page resolved to a
+ /// real state — EITHER all 5 KPI tile labels rendered OR the 'KPIs unavailable'
+ /// alert is shown.
+ ///
+ [SkippableFact]
+ public async Task KpisPage_RendersTilesOrError()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _pw.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{KpisUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await Assertions.Expect(page.Locator("h4:has-text('Notification KPIs')")).ToBeVisibleAsync();
+
+ var tilesOk = await page.Locator("small.text-muted:has-text('Queue Depth')").IsVisibleAsync()
+ && await page.Locator("small.text-muted:has-text('Oldest Pending Age')").IsVisibleAsync();
+ var errShown = await page.Locator(".alert.alert-warning:has-text('KPIs unavailable')").IsVisibleAsync();
+ Assert.True(tilesOk || errShown,
+ "Expected either the 5 KPI tiles or the 'KPIs unavailable' alert to render.");
+
+ if (tilesOk)
+ {
+ await Assertions.Expect(page.Locator("small.text-muted:has-text('Stuck')")).ToBeVisibleAsync(new() { Timeout = 5_000 });
+ await Assertions.Expect(page.Locator("small.text-muted:has-text('Parked')")).ToBeVisibleAsync(new() { Timeout = 5_000 });
+ await Assertions.Expect(page.Locator("small.text-muted:has-text('Delivered (last interval)')")).ToBeVisibleAsync(new() { Timeout = 5_000 });
+ }
+ }
+
+ ///
+ /// Navigates to the Notification KPIs page, clicks the Refresh button, and asserts
+ /// that the button re-enables within 10 s — proving the refresh round-trip completed
+ /// without hanging.
+ ///
+ [SkippableFact]
+ public async Task KpisPage_RefreshReenables()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _pw.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{KpisUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var refresh = page.Locator("button.btn.btn-outline-secondary.btn-sm:has-text('Refresh')");
+ await Assertions.Expect(refresh).ToBeEnabledAsync(new() { Timeout = 10_000 });
+ await refresh.ClickAsync();
+ await Assertions.Expect(refresh).ToBeEnabledAsync(new() { Timeout = 10_000 });
+ }
+}