diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs
new file mode 100644
index 00000000..2e14d7d2
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationListCrudTests.cs
@@ -0,0 +1,128 @@
+using Microsoft.Playwright;
+using Xunit;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
+
+///
+/// Full-UI round-trip coverage for Notification Lists (/notifications/lists).
+///
+///
+/// This page requires the Design role; the test user multi-role has it. The
+/// happy-path fact drives the entire lifecycle through the UI only — create the list,
+/// open it for edit, add a recipient, remove that recipient, then delete the list and
+/// assert the confirm-dialog + success-toast + row-gone sequence. A finally block
+/// performs best-effort CLI teardown so no zztest-notiflist-* list leaks on failure.
+/// The validation fact mutates nothing.
+///
+///
+[Collection("Playwright")]
+public class NotificationListCrudTests
+{
+ private const string ListsUrl = "/notifications/lists";
+
+ private readonly PlaywrightFixture _pw;
+
+ public NotificationListCrudTests(PlaywrightFixture pw)
+ {
+ _pw = pw;
+ }
+
+ ///
+ /// Drives the complete Notification List lifecycle through the UI: create the list,
+ /// open it for edit, add a recipient, remove that recipient, then delete the list —
+ /// asserting the delete confirm dialog, the success toast, and the row disappearing.
+ ///
+ [SkippableFact]
+ public async Task Create_AddRecipient_RemoveRecipient_Delete_RoundTrips()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var name = CliRunner.UniqueName("notiflist");
+ const string recipEmail = "zzrec@example.invalid";
+
+ var page = await _pw.NewAuthenticatedPageAsync();
+
+ try
+ {
+ // ── CREATE ────────────────────────────────────────────────────────────────
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}/create");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await Assertions.Expect(page.Locator("h4:has-text('Add Notification List')")).ToBeVisibleAsync();
+
+ // The create form has exactly one input (the list name); recipients are edit-only.
+ await page.Locator("input.form-control").First.FillAsync(name);
+ await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
+
+ // On success the form redirects back to the list page (no toast).
+ await PlaywrightFixture.WaitForPathAsync(page, ListsUrl, excludePath: "/create");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var listRow = page.Locator("tr").Filter(new() { HasText = name });
+ await Assertions.Expect(listRow).ToBeVisibleAsync();
+
+ // ── ADD RECIPIENT ─────────────────────────────────────────────────────────
+ await listRow.Locator("button.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
+
+ await Assertions.Expect(page.Locator("h4:has-text('Edit Notification List')")).ToBeVisibleAsync();
+
+ // The edit page has TWO "Name" text inputs (list name + recipient name). Scope the
+ // recipient form to the card that contains the email input to disambiguate.
+ var recipientCard = page.Locator(".card").Filter(new() { Has = page.Locator("input[type=email]") });
+ await recipientCard.Locator("input[type=text].form-control").FillAsync("zzrec");
+ await recipientCard.Locator("input[type=email]").FillAsync(recipEmail);
+ await recipientCard.Locator("button.btn-success:has-text('Add')").ClickAsync();
+
+ var recipientRow = page.Locator("tr").Filter(new() { HasText = recipEmail });
+ await Assertions.Expect(recipientRow).ToBeVisibleAsync();
+
+ // ── REMOVE RECIPIENT ──────────────────────────────────────────────────────
+ // Direct delete — no confirm dialog.
+ await recipientRow.Locator("button.btn-outline-danger.btn-sm:has-text('Delete')").ClickAsync();
+ await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = recipEmail }))
+ .ToHaveCountAsync(0, new() { Timeout = 10_000 });
+
+ // ── DELETE LIST ───────────────────────────────────────────────────────────
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ var listRowAgain = page.Locator("tr").Filter(new() { HasText = name });
+ await listRowAgain.Locator("button.btn-outline-danger.btn-sm:has-text('Delete')").ClickAsync();
+
+ // Confirm the global danger dialog.
+ await Assertions.Expect(page.Locator(".modal-footer .btn-danger")).ToBeVisibleAsync();
+ await page.Locator(".modal-footer .btn-danger").ClickAsync();
+
+ // Success toast appears (auto-dismisses at 5s, so assert promptly) and the row is gone.
+ await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
+ await Assertions.Expect(page.Locator(".toast-body:has-text('Deleted.')")).ToBeVisibleAsync();
+ await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = name }))
+ .ToHaveCountAsync(0, new() { Timeout = 10_000 });
+ }
+ finally
+ {
+ foreach (var id in await CliRunner.ListNotificationListIdsByNamePrefixAsync(name))
+ await CliRunner.DeleteNotificationListAsync(id);
+ }
+ }
+
+ ///
+ /// Submits the create form with a blank name and asserts the inline 'Name required.'
+ /// validation error appears and the page does not navigate away. Mutates nothing.
+ ///
+ [SkippableFact]
+ public async Task CreateForm_EmptyName_ShowsInlineError()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _pw.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ListsUrl}/create");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
+
+ await Assertions.Expect(page.Locator("div.text-danger.small:has-text('Name required.')")).ToBeVisibleAsync();
+ Assert.Contains("/create", page.Url);
+ }
+}