diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs
new file mode 100644
index 00000000..5a03a475
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/DataConnectionCrudTests.cs
@@ -0,0 +1,103 @@
+using Microsoft.Playwright;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+using Xunit;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
+
+[Collection("Playwright")]
+public class DataConnectionCrudTests
+{
+ private readonly PlaywrightFixture _pw;
+
+ public DataConnectionCrudTests(PlaywrightFixture pw)
+ {
+ _pw = pw;
+ }
+
+ ///
+ /// CLI-create a data connection on site-a, then exercise the high-value UI delete
+ /// path: the connection renders as a child node in the TreeView, the per-node kebab
+ /// → Delete → confirm dialog removes it, a success toast fires, and the node is gone.
+ /// The create form requires a brittle OPC UA endpoint sub-editor, so we seed via the
+ /// CLI (a minimal create needs no primary config) and only drive the delete via the UI.
+ /// The best-effort finally is a safety net: the happy path deletes via the UI.
+ ///
+ [SkippableFact]
+ public async Task CliCreated_Connection_DeletesViaTree()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var siteId = await CliRunner.ResolveSiteIdAsync("site-a");
+ var name = CliRunner.UniqueName("dconn");
+ var id = await CliRunner.CreateDataConnectionAsync(siteId, name);
+
+ try
+ {
+ var page = await _pw.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/connections");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await Assertions.Expect(page.Locator("h4:has-text('Connections')")).ToBeVisibleAsync();
+
+ // Reveal all nodes so the new connection (a child of its site) is in the tree.
+ await page.Locator("button.btn-outline-secondary.btn-sm.dropdown-toggle:has-text('Bulk actions')")
+ .ClickAsync();
+ await page.Locator(".dropdown-item:has-text('Expand all')").ClickAsync();
+
+ var node = page.Locator("span.tv-label", new() { HasText = name });
+ await Assertions.Expect(node).ToBeVisibleAsync(new() { Timeout = 10_000 });
+
+ // The kebab is opacity:0 until its row is hovered; hovering first makes the
+ // reveal deterministic, then a forced click sidesteps any residual flake.
+ await node.HoverAsync();
+ await page.Locator($"button.dc-kebab[aria-label='More actions for {name}']")
+ .ClickAsync(new() { Force = true });
+
+ // Every connection node renders its own .text-danger "Delete" item, so scope the
+ // click to the one open menu. Bootstrap puts .show on the active node's
+ // ul.dropdown-menu (and auto-closes the others), making this match uniquely.
+ await page.Locator(".dropdown-menu.show .dropdown-item.text-danger:has-text('Delete')")
+ .ClickAsync();
+
+ await Assertions.Expect(page.Locator(".modal-title:has-text('Delete Connection')")).ToBeVisibleAsync();
+ await page.Locator(".modal-footer .btn-danger").ClickAsync();
+
+ // Single web-first assertion on the success toast — toasts auto-dismiss at 5s,
+ // so we do NOT chase the toast body in a second sequential check.
+ await Assertions.Expect(page.Locator(".toast", new() { HasText = "deleted" }))
+ .ToHaveCountAsync(1, new() { Timeout = 15_000 });
+
+ // The node must be gone from the tree.
+ await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = name }))
+ .ToHaveCountAsync(0, new() { Timeout = 10_000 });
+ }
+ finally
+ {
+ // Best-effort teardown: the happy path deletes via the UI, so this finds
+ // nothing; it only fires if the UI path failed mid-way.
+ await CliRunner.DeleteDataConnectionAsync(id);
+ }
+ }
+
+ ///
+ /// The "+ Connection" button is gated on a tree-node selection: it starts disabled
+ /// and only enables once a site (or connection) node is selected. Mutates nothing.
+ ///
+ [SkippableFact]
+ public async Task CreateButton_GatedOnNodeSelection()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _pw.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/connections");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await Assertions.Expect(page.Locator("h4:has-text('Connections')")).ToBeVisibleAsync();
+
+ var addButton = page.Locator("button.btn.btn-primary.btn-sm:has-text('+ Connection')");
+ await Assertions.Expect(addButton).ToBeDisabledAsync();
+
+ // Selecting any site node (site-a exists on the live cluster) satisfies the gate.
+ await page.Locator("span.tv-label").First.ClickAsync();
+
+ await Assertions.Expect(addButton).ToBeEnabledAsync();
+ }
+}