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. } } }