From f08a4d609f37b74057561fca988e63e753cc5aac Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 19 Jun 2026 04:31:38 -0400 Subject: [PATCH] =?UTF-8?q?test(playwright):=20M9=20surface=20coverage=20?= =?UTF-8?q?=E2=80=94=20template=20search,=20schema-library,=20move-connect?= =?UTF-8?q?ion=20(#263)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Design/M9SurfaceTests.cs | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/M9SurfaceTests.cs 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. + } + } +}