test(playwright): M9 surface coverage — template search, schema-library, move-connection (#263)
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Browser-level coverage for the M9 design surfaces, run against the live dev cluster:
|
||||
/// <list type="bullet">
|
||||
/// <item>the <c>/design/templates</c> tree search filter (TemplateFolderTree pruning),</item>
|
||||
/// <item>the <c>/design/schema-library</c> page (render-smoke + UI create→delete round-trip),</item>
|
||||
/// <item>the <c>/design/connections</c> "Move to Site…" dialog render (MoveDataConnectionDialog).</item>
|
||||
/// </list>
|
||||
/// Every test seeds only uniquely-named throwaway entities (<c>zztest-*</c>) and best-effort
|
||||
/// cleans them up; none mutate the canonical <c>site-a</c>/<c>site-b</c>/<c>site-c</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.
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class M9SurfaceTests
|
||||
{
|
||||
private readonly PlaywrightFixture _pw;
|
||||
|
||||
public M9SurfaceTests(PlaywrightFixture pw)
|
||||
{
|
||||
_pw = pw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type a unique needle into the Templates page search box and confirm
|
||||
/// <see cref="TemplateFolderTree"/> 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 <c>_visibleRoots</c> from matches only — non-matches are gone,
|
||||
/// not CSS-hidden — so a <c>ToHaveCountAsync(0)</c> on the non-match is the correct assertion.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>lib:Name</c> 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 <c>finally</c> only fires if the UI delete failed mid-flow.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-seed a throwaway connection on site-a, open its kebab → "Move to Site…", and assert the
|
||||
/// MoveDataConnectionDialog opens with its site <c><select></c> 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user