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