diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs
new file mode 100644
index 00000000..61b53bad
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/SmsNotificationE2ETests.cs
@@ -0,0 +1,271 @@
+using System.Text.Json;
+using Microsoft.Playwright;
+using Xunit;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Notifications;
+
+///
+/// Live end-to-end coverage for the SMS Notifications feature against the running dev
+/// cluster. Three facts, each exercising one of the new SMS surfaces:
+///
+///
+/// — the SMS
+/// Configuration page (/notifications/sms, RequireAdmin; multi-role
+/// has Admin). Doubles as the RENDER-CRASH GATE for the new page: if the Blazor circuit
+/// crashed on render, the heading/form interaction below would hang or fail. Also asserts
+/// live persistence (the encrypted AuthToken round-trips into real MS SQL) and that the
+/// secret VALUE never appears anywhere in the rendered page (presence indicator only).
+/// — the
+/// /notifications/lists/create Type selector flips the recipient contact field
+/// from email to phone when Type = SMS.
+/// — a list created with
+/// Type = SMS renders "Sms" in the Type column on /notifications/lists.
+///
+///
+///
+/// Idempotency: the SMS-config fact uses a STABLE Account SID (ACtest123) and probes
+/// notification sms list first — if a prior run already created it, the fact SKIPS the
+/// create and verifies the existing row instead (the page exposes no delete verb — neither UI
+/// nor CLI — so we never create a second copy we could not clean up; the render-crash +
+/// non-leak assertions still run against the existing row). The notification-list facts use a
+/// name with best-effort CLI teardown in a finally.
+///
+///
+[Collection("Playwright")]
+public class SmsNotificationE2ETests
+{
+ private const string SmsUrl = "/notifications/sms";
+ private const string ListsUrl = "/notifications/lists";
+
+ // Stable test fixture values. The Account SID is stable (not random) so reruns find the
+ // prior config via 'notification sms list' and skip re-creation — the SMS config page has
+ // no delete verb, so a unique-per-run SID would leak a config on every run.
+ private const string TestAccountSid = "ACtest123";
+ private const string TestFromNumber = "+15551230000";
+ // The Auth Token VALUE that must NEVER be echoed back to the page (presence flag only).
+ private const string TestAuthTokenValue = "e2e-secret-token-xyz";
+
+ private readonly PlaywrightFixture _pw;
+
+ public SmsNotificationE2ETests(PlaywrightFixture pw)
+ {
+ _pw = pw;
+ }
+
+ ///
+ /// Render-crash gate + live persistence + secret-non-leak for the SMS Configuration page.
+ ///
+ ///
+ /// Probes notification sms list; if no config with
+ /// exists, fills and saves the create form (Account SID, From Number, Auth Token; other
+ /// fields default) — asserting the "SMS configuration saved." toast, which confirms the
+ /// encrypted token persisted to real MS SQL. Whether the row was just created or already
+ /// existed, the fact then asserts (a) the config card for is
+ /// rendered with the Auth Token shown as "(stored)" — a presence flag — and (b) the secret
+ /// VALUE appears NOWHERE in the page HTML.
+ ///
+ ///
+ ///
+ /// The render-crash gate is implicit and strong: a circuit that crashed while rendering the
+ /// new page would never surface the SMS Configuration heading, never accept the form
+ /// fills, and never raise the saved toast — all of which are asserted with web-first waits.
+ ///
+ ///
+ [SkippableFact]
+ public async Task SmsConfigPage_CreateOrRender_NeverLeaksAuthToken()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ // Probe whether our stable-SID config already exists (a prior run). The SMS config page
+ // exposes no delete verb, so we must not create a second copy we cannot clean up.
+ bool alreadyExists = await SmsConfigExistsAsync(TestAccountSid);
+
+ var page = await _pw.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SmsUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // RENDER-CRASH GATE: the heading only appears if the new page's circuit rendered cleanly.
+ await Assertions.Expect(page.Locator("h4:has-text('SMS Configuration')")).ToBeVisibleAsync();
+
+ if (!alreadyExists)
+ {
+ // Open the create form. When no config exists the page shows the empty-state
+ // "Add SMS configuration" button (rendered both in the empty-state block and the
+ // fall-through else); .First disambiguates if both are momentarily present.
+ await page.Locator("button.btn-primary.btn-sm:has-text('Add SMS configuration')").First.ClickAsync();
+
+ // The form's text inputs, in document order: Account SID, From Number, Messaging
+ // Service SID, API Base URL. We fill the first two and leave the rest default.
+ var textInputs = page.Locator("input[type=text].form-control");
+ await textInputs.Nth(0).FillAsync(TestAccountSid);
+ await textInputs.Nth(1).FillAsync(TestFromNumber);
+
+ // Auth Token is the single password input; required on create.
+ await page.Locator("input[type=password].form-control").FillAsync(TestAuthTokenValue);
+
+ await page.Locator("button.btn-success:has-text('Save')").ClickAsync();
+
+ // Live-persistence proof: the saved toast only fires after AddSmsConfiguration +
+ // SaveChanges succeed against MS SQL (the encrypted AuthToken is now stored).
+ await Assertions
+ .Expect(page.Locator(".toast", new() { HasText = "SMS configuration saved." }))
+ .ToHaveCountAsync(1, new() { Timeout = 15_000 });
+ }
+
+ // Whether just-created or pre-existing: the config card for our SID must be rendered,
+ // with the Auth Token shown as the presence flag "(stored)" — never the value.
+ var configCard = page.Locator(".card").Filter(new() { HasText = TestAccountSid });
+ await Assertions.Expect(configCard.First).ToBeVisibleAsync(new() { Timeout = 10_000 });
+ await Assertions.Expect(page.GetByText("(stored)").First).ToBeVisibleAsync(new() { Timeout = 10_000 });
+
+ // SECRET-NON-LEAK: the raw Auth Token value must not appear anywhere in the page HTML.
+ // (The form is closed after save, so the password input that briefly held it is gone;
+ // the rendered card shows only the "(stored)" presence flag.)
+ var html = await page.ContentAsync();
+ Assert.DoesNotContain(TestAuthTokenValue, html, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Asserts the /notifications/lists/create Type selector is channel-aware on the
+ /// EDIT surface: a list created with Type = SMS renders a phone (input[type=tel])
+ /// recipient field and NOT an email (input[type=email]) field, then accepts a phone
+ /// recipient. (Recipient inputs render only after the list exists — the create page persists
+ /// the Type, the edit page then exposes the matching contact field.)
+ ///
+ ///
+ /// Drives the whole flow through the UI: create (Type = SMS) → redirect to the list →
+ /// open Edit → assert the phone input is present and the email input is absent → add a
+ /// phone recipient and assert the row appears. Best-effort CLI teardown in finally.
+ ///
+ ///
+ [SkippableFact]
+ public async Task ListCreate_SmsType_ShowsPhoneInput_NotEmail()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var name = CliRunner.UniqueName("sms-list");
+ const string recipPhone = "+15559998888";
+
+ var page = await _pw.NewAuthenticatedPageAsync();
+ try
+ {
+ // ── CREATE with Type = SMS ──────────────────────────────────────────────────
+ 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 the list-name text input and the Type