diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/M9SurfaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/M9SurfaceTests.cs
new file mode 100644
index 00000000..f0bd743e
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/M9SurfaceTests.cs
@@ -0,0 +1,236 @@
+using Microsoft.Data.SqlClient;
+using Microsoft.Playwright;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+using Xunit;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
+
+///
+/// Browser-level coverage for the M9 design surfaces, run against the live dev cluster:
+///
+/// - the /design/templates tree search filter (TemplateFolderTree pruning),
+/// - the /design/schema-library page (render-smoke + UI create→delete round-trip),
+/// - the /design/connections "Move to Site…" dialog render (MoveDataConnectionDialog).
+///
+/// Every test seeds only uniquely-named throwaway entities (zztest-*) and best-effort
+/// cleans them up; none mutate the canonical site-a/site-b/site-c rows or any
+/// pre-seeded entity. The move test asserts the dialog renders its picker only — it never
+/// completes the move, so shared site state is untouched.
+///
+[Collection("Playwright")]
+public class M9SurfaceTests
+{
+ private readonly PlaywrightFixture _pw;
+
+ public M9SurfaceTests(PlaywrightFixture pw)
+ {
+ _pw = pw;
+ }
+
+ ///
+ /// Type a unique needle into the Templates page search box and confirm
+ /// prunes the tree: the matching template's label is
+ /// shown while a sibling throwaway template (different unique suffix) is removed from the
+ /// DOM. The filter rebuilds _visibleRoots from matches only — non-matches are gone,
+ /// not CSS-hidden — so a ToHaveCountAsync(0) on the non-match is the correct assertion.
+ ///
+ [SkippableFact]
+ public async Task TemplateSearch_FiltersTree()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ // Two root templates with disjoint unique names. We search for `matchName` and assert
+ // `otherName` is filtered out — both share the zztest- prefix but differ past it, so a
+ // substring search on the full match name cannot also match the other.
+ var matchName = CliRunner.UniqueName("tmplmatch");
+ var otherName = CliRunner.UniqueName("tmplother");
+ var matchId = await CliRunner.CreateTemplateAsync(matchName);
+ var otherId = await CliRunner.CreateTemplateAsync(otherName);
+
+ try
+ {
+ var page = await _pw.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ await Assertions.Expect(page.Locator("h4:has-text('Templates')")).ToBeVisibleAsync();
+
+ // Both templates are root nodes, so both labels are present before filtering.
+ await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = matchName }))
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+ await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = otherName }))
+ .ToBeVisibleAsync(new() { Timeout = 15_000 });
+
+ // Type the full match name into the search box (placeholder "Search templates...").
+ // @oninput drives _searchText → TemplateFolderTree.Filter → ApplyFilter on each keystroke.
+ await page.Locator("input[placeholder='Search templates...']").FillAsync(matchName);
+
+ // The match remains; the other template is pruned out of the tree entirely.
+ await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = matchName }))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+ await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = otherName }))
+ .ToHaveCountAsync(0, new() { Timeout = 10_000 });
+
+ // Clearing the search via the ✕ button restores the full tree (both labels back).
+ await page.Locator("button[title='Clear search']").ClickAsync();
+ await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = otherName }))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+ }
+ finally
+ {
+ await CliRunner.DeleteTemplateAsync(matchId);
+ await CliRunner.DeleteTemplateAsync(otherId);
+ }
+ }
+
+ ///
+ /// Full UI round-trip on the Schema Library page: open the editor with "New Schema", save a
+ /// uniquely-named library schema (seeded with the page's default object schema), confirm it
+ /// lands in the table and renders its lib:Name reference, then delete it via the row's
+ /// Delete button + the global DialogHost confirm. Doubles as the page render-smoke: the page,
+ /// the SchemaBuilder editor, and the success/delete toasts all render with no circuit crash.
+ /// The DB safety-net finally only fires if the UI delete failed mid-flow.
+ ///
+ [SkippableFact]
+ public async Task SchemaLibrary_CreateDelete_RoundTrips()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ // Schema names allow only a constrained identifier set, and the CLI has no schema
+ // command, so build a unique-but-valid name from the GUID suffix (letters/digits only).
+ var name = "Zztest" + Guid.NewGuid().ToString("N")[..10];
+ var page = await _pw.NewAuthenticatedPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/schema-library");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ // ── RENDER-SMOKE ──────────────────────────────────────────────────────────────
+ // The page heading + New Schema button must render (no circuit crash on load).
+ await Assertions.Expect(page.Locator("h4:has-text('Schema Library')")).ToBeVisibleAsync();
+ await page.ClickAsync("button.btn.btn-primary.btn-sm:has-text('New Schema')");
+
+ // The editor card opens with the SchemaBuilder; its Name input is now visible.
+ // (If the SchemaBuilder threw on render, the circuit would tear down and this fails.)
+ var nameInput = page.Locator("input[placeholder='e.g. Address']");
+ await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 10_000 });
+
+ // ── CREATE ────────────────────────────────────────────────────────────────────
+ await nameInput.FillAsync(name);
+ await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
+
+ // Success toast fires and the row appears in the table with its lib: reference.
+ await Assertions.Expect(page.Locator(".toast", new() { HasText = "created" }))
+ .ToHaveCountAsync(1, new() { Timeout = 15_000 });
+ await Assertions.Expect(page.Locator($"td.fw-semibold:has-text('{name}')"))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+ await Assertions.Expect(page.Locator($"td code:has-text('lib:{name}')"))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+
+ // ── DELETE ────────────────────────────────────────────────────────────────────
+ // Scope the Delete click to this schema's own row so we never click a sibling's button.
+ var row = page.Locator("tr").Filter(new() { Has = page.Locator($"td.fw-semibold:has-text('{name}')") });
+ await row.Locator("button.btn-outline-danger:has-text('Delete')").ClickAsync();
+
+ // Global DialogHost confirm modal (has `fade`); the danger button is labelled "Delete".
+ var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')");
+ await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 });
+ await confirmBtn.ClickAsync();
+
+ // The row is gone after delete.
+ await Assertions.Expect(page.Locator($"td.fw-semibold:has-text('{name}')"))
+ .ToHaveCountAsync(0, new() { Timeout = 10_000 });
+ }
+ finally
+ {
+ // Best-effort DB safety-net: the happy path deletes via the UI, so this finds
+ // nothing; it only removes an orphan if the UI delete failed mid-flow. There is no
+ // CLI schema command, so this is a direct DELETE on the uniquely-named row.
+ await TryDeleteSchemaByNameAsync(name);
+ }
+ }
+
+ ///
+ /// CLI-seed a throwaway connection on site-a, open its kebab → "Move to Site…", and assert the
+ /// MoveDataConnectionDialog opens with its site <select> picker rendered. This does
+ /// NOT submit the move — the dialog is cancelled — so no shared site state is mutated. Two-plus
+ /// sites exist on the live cluster, so the picker (rather than the "no other site" fallback)
+ /// renders.
+ ///
+ [SkippableFact]
+ public async Task MoveConnectionDialog_OpensWithSitePicker()
+ {
+ 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.
+ var bulkDropdown = page.Locator(".dropdown")
+ .Filter(new() { Has = page.Locator("button.dropdown-toggle:has-text('Bulk actions')") });
+ await bulkDropdown.Locator("button.dropdown-toggle:has-text('Bulk actions')").ClickAsync();
+ await bulkDropdown.Locator(".dropdown-menu .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 });
+
+ // Scope all dropdown interaction to THIS node's own .dc-node-actions wrapper, keyed by
+ // the unique aria-label (mirrors DataConnectionCrudTests' strict-mode-safe approach).
+ var nodeActions = page.Locator(".dc-node-actions")
+ .Filter(new() { Has = page.Locator($"button[aria-label='More actions for {name}']") });
+
+ await node.HoverAsync();
+ await nodeActions.Locator("button.dc-kebab").ClickAsync(new() { Force = true });
+
+ await nodeActions.Locator(".dropdown-menu .dropdown-item:has-text('Move to Site…')")
+ .ClickAsync();
+
+ // The DialogHost custom dialog opens with the move title and the site picker select.
+ await Assertions.Expect(page.Locator($".modal-title:has-text(\"Move '{name}' to site…\")"))
+ .ToBeVisibleAsync(new() { Timeout = 10_000 });
+ var picker = page.Locator(".modal-body select.form-select");
+ await Assertions.Expect(picker).ToBeVisibleAsync(new() { Timeout = 10_000 });
+
+ // The picker must offer at least one candidate site (site-b/site-c exist on the live
+ // cluster), proving the picker — not the "No other site is available" fallback — rendered.
+ Assert.True(await picker.Locator("option").CountAsync() >= 1);
+
+ // Cancel — DO NOT submit. No shared site state is mutated.
+ await page.Locator(".modal-footer .btn-outline-secondary:has-text('Cancel')").ClickAsync();
+ }
+ finally
+ {
+ await CliRunner.DeleteDataConnectionAsync(id);
+ }
+ }
+
+ ///
+ /// Best-effort direct DELETE of a library schema by name, for teardown. There is no CLI
+ /// schema command, so the test owns this fallback. Swallows every failure so it can never
+ /// mask the original test outcome.
+ ///
+ private static async Task TryDeleteSchemaByNameAsync(string name)
+ {
+ try
+ {
+ await using var conn = new SqlConnection(PlaywrightDbConnection.ConnectionString);
+ await conn.OpenAsync();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "DELETE FROM SharedSchemas WHERE Name = @name;";
+ cmd.Parameters.AddWithValue("@name", name);
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch
+ {
+ // Best-effort — never fail (or mask) the test on teardown of a uniquely-named row.
+ }
+ }
+}