014038fbbd
All 14 pre-existing Playwright failures triage to selector drift, not rig data,
timing, or application defects — every one is a test-side assumption that two
merged UI refactors invalidated without updating this suite.
`9e243493` (2026-08-11 density sweep + Theme 0.4.1) regrouped page/dialog action
buttons into `btn-group btn-group-sm`, moving the size class off the button and
onto the group. Twelve selectors still pinned `.btn-sm` on the button itself:
ConfigurationAuditLog's Search (4 tests), the Topology Create/Move dialog footers
(3), the Notification Lists row Edit/Delete (2), and the Notification Report row
Retry/Discard (2). The same sweep re-cast SiteForm's per-node subsections from
bare `<h6>` headings to Bootstrap cards, so `h6:has-text('Node A')` matches
nothing (1). Note the swept surfaces are a subset — the recipient table on the
notification-list edit form, OffsetPager, DiffDialog and friends still carry
`.btn-sm` on the button, so the selectors are deliberately asymmetric; the
remaining `.btn-sm` selectors in this suite were each re-verified against current
markup rather than swept along.
`a506b19d` moved TemplateEdit's page-embedded modals onto the DialogService host.
The attribute modal is now the DialogHost modal (`modal fade show d-block`, title
in an h5), so the `:not(.fade)` guard written to exclude DialogHost — plus the
`h6.modal-title` assertion — excluded the very modal under test (2). TemplateEdit
renders no page-local modal any more, so the guard has nothing left to guard.
Comments at each site record why the size class is absent, so a future reader
does not "fix" the selectors back.
Verified: 14 failed / 158 passed baseline -> the 36 tests in the seven affected
classes all green; solution build 0 warnings / 0 errors.
345 lines
17 KiB
C#
345 lines
17 KiB
C#
using Microsoft.Playwright;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
|
|
|
|
/// <summary>
|
|
/// End-to-end round-trip for the Template design pages:
|
|
/// create → add attribute → delete, all via the Central UI against the
|
|
/// running dev cluster.
|
|
/// </summary>
|
|
[Collection("Playwright")]
|
|
public class TemplateCrudTests : IClassFixture<DeploymentFixture>
|
|
{
|
|
private readonly PlaywrightFixture _fixture;
|
|
private readonly DeploymentFixture _cluster;
|
|
|
|
public TemplateCrudTests(PlaywrightFixture fixture, DeploymentFixture cluster)
|
|
{
|
|
_fixture = fixture;
|
|
_cluster = cluster;
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task CreateAddAttributeDelete_Template_RoundTrips()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var name = $"zztest-tmpl-{Guid.NewGuid().ToString("N")[..8]}";
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
try
|
|
{
|
|
// ── CREATE ────────────────────────────────────────────────────────────────
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Name input is label-anchored: <label class="form-label">Name</label>
|
|
// followed by <input class="form-control" @bind="_createName" />.
|
|
// Use the label's sibling input scoped to the containing div to avoid any
|
|
// strict-mode violation from the Description input (also form-control).
|
|
await page.Locator("div.mb-3:has(label:has-text('Name')) input.form-control").FillAsync(name);
|
|
|
|
// Leave Parent Template at the default "(None - root template)".
|
|
// Click the green Create button.
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Create')");
|
|
|
|
// After a successful create, Blazor navigates to /design/templates/{id}.
|
|
// Poll window.location until the path matches /design/templates/ + digits
|
|
// and does not still say /create.
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/design/templates/", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Sanity: we must be on a numeric template detail URL.
|
|
Assert.Matches(@"/design/templates/\d+$", page.Url);
|
|
|
|
// ── ADD ATTRIBUTE ─────────────────────────────────────────────────────────
|
|
// The Attributes tab is the default-active tab (_activeTab = "attributes"),
|
|
// so we don't need to click it, but we do wait for the tab panel to render.
|
|
await Assertions.Expect(
|
|
page.Locator("button.nav-link:has-text('Attributes')"))
|
|
.ToBeVisibleAsync();
|
|
|
|
// Click Add Attribute.
|
|
await page.ClickAsync("button.btn.btn-primary.btn-sm:has-text('Add Attribute')");
|
|
|
|
// TemplateEdit's four page-embedded modals were migrated onto the global
|
|
// DialogService host, so the Add-Attribute modal IS the DialogHost modal now
|
|
// (`modal fade show d-block`, title in an h5). The old `:not(.fade)` guard
|
|
// against the page-local variant would exclude it — TemplateEdit renders no
|
|
// page-local modal of its own any more, so `.modal.show.d-block` is
|
|
// unambiguous on this page.
|
|
var modal = page.Locator(".modal.show.d-block");
|
|
await Assertions.Expect(modal).ToBeVisibleAsync();
|
|
await Assertions.Expect(modal.Locator(".modal-title")).ToHaveTextAsync("Add Attribute");
|
|
|
|
// Fill Name field inside the modal.
|
|
await modal.Locator("div.col-12:has(label:has-text('Name')) input.form-control").FillAsync("Val");
|
|
|
|
// Select Data Type = Double.
|
|
// The select is label-anchored: <label>Data Type</label> + <select class="form-select">.
|
|
await modal.Locator("div.col-12:has(label:has-text('Data Type')) select.form-select").SelectOptionAsync("Double");
|
|
|
|
// Click the footer Add button (btn-success, text "Add"). The dialog feet were
|
|
// wrapped in a btn-group btn-group-sm by the 2026-08-11 UI sweep, so the size
|
|
// class now lives on the group rather than on the button itself.
|
|
await modal.Locator(".modal-footer button.btn-success:has-text('Add')").ClickAsync();
|
|
|
|
// The modal should dismiss and the attribute table should show "Val".
|
|
await Assertions.Expect(modal).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
await Assertions.Expect(page.Locator("table td:has-text('Val')"))
|
|
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
|
|
|
// ── DELETE ────────────────────────────────────────────────────────────────
|
|
// Click the header Delete button (btn-outline-danger btn-sm, text "Delete").
|
|
await page.ClickAsync("button.btn.btn-outline-danger:has-text('Delete')");
|
|
|
|
// The global DialogHost renders a confirm dialog.
|
|
// The danger confirm button carries class "btn-danger" and text "Delete".
|
|
var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')");
|
|
await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
|
await confirmBtn.ClickAsync();
|
|
|
|
// After delete, Blazor navigates back to /design/templates (the list page).
|
|
// excludePath: "/design/templates/" rejects any /design/templates/{id} detail URL.
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/design/templates", excludePath: "/design/templates/");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The template name must no longer appear in the tree view.
|
|
await Assertions.Expect(
|
|
page.Locator("span.tv-label", new() { HasText = name }))
|
|
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort safety net: clean up any leftover zztest-tmpl-* templates
|
|
// that were not deleted by the UI (e.g. test failed mid-flow).
|
|
try
|
|
{
|
|
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — swallow to avoid masking the original test failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task CreateTemplate_DuplicateName_ShowsInlineError()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
// CLI-seed an existing base template, then UI-attempt to create a duplicate.
|
|
// Base (non-derived) Template.Name has a unique index
|
|
// (HasIndex(t => t.Name).IsUnique().HasFilter("[IsDerived]=0")) and
|
|
// TemplateService.CreateTemplateAsync has no friendly duplicate pre-check,
|
|
// so the DB-constraint exception is caught into _formError and rendered inline
|
|
// in div.text-danger.small with no navigation (stays on /create).
|
|
// (Empirically confirmed: duplicate create surfaces inline; duplicate-name path used.)
|
|
var name = CliRunner.UniqueName("tmpl");
|
|
var seededId = await CliRunner.CreateTemplateAsync(name);
|
|
|
|
try
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Fill the Name input with the duplicate name and click Create.
|
|
await page.Locator("div.mb-3:has(label:has-text('Name')) input.form-control").FillAsync(name);
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Create')");
|
|
|
|
// Web-first assertions: the inline error becomes visible and we stay on /create.
|
|
// Do NOT assert a literal message — it is the DB-constraint exception text.
|
|
await Assertions.Expect(page.Locator("div.text-danger.small")).ToBeVisibleAsync();
|
|
await Assertions.Expect(page)
|
|
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/design/templates/create"));
|
|
}
|
|
finally
|
|
{
|
|
// Delete the seeded source template, then sweep any leftover by name.
|
|
await CliRunner.DeleteTemplateAsync(seededId);
|
|
try
|
|
{
|
|
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — swallow to avoid masking the original test failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task CreateTemplate_Cancel_ReturnsToListWithoutCreating()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var name = CliRunner.UniqueName("tmpl");
|
|
|
|
try
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Fill the Name input, then click Cancel — Blazor navigates back to the list.
|
|
await page.Locator("div.mb-3:has(label:has-text('Name')) input.form-control").FillAsync(name);
|
|
await page.ClickAsync("button.btn.btn-outline-secondary:has-text('Cancel')");
|
|
|
|
// excludePath: "/create" rejects the /design/templates/create URL we came from.
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/design/templates", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Nothing was created: no template exists with our unique name.
|
|
Assert.Empty(await CliRunner.ListTemplateIdsByNamePrefixAsync(name));
|
|
}
|
|
finally
|
|
{
|
|
// Defensive sweep by name in case of an unexpected create.
|
|
try
|
|
{
|
|
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — swallow to avoid masking the original test failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task EditAttribute_PersistsChange()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
// CLI-seed a template with a single Double "Val" attribute, then edit its
|
|
// Value through the page-local Edit-Attribute modal and confirm it persists.
|
|
var name = CliRunner.UniqueName("tmpl");
|
|
var id = await CliRunner.CreateTemplateAsync(name);
|
|
await CliRunner.AddAttributeAsync(id, "Val", "Double");
|
|
|
|
try
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/{id}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Open the Val row's actions dropdown, then click the "Edit…" item
|
|
// (note the ellipsis char "…" — it is in the markup verbatim).
|
|
await page.ClickAsync("button[aria-label=\"More actions for Val\"]");
|
|
await page.ClickAsync("button.dropdown-item:has-text('Edit…')");
|
|
|
|
// The attribute modal is rendered by the global DialogService host since
|
|
// TemplateEdit's page-embedded modals were migrated to it — `modal fade show
|
|
// d-block` with the title in an h5. TemplateEdit renders no page-local modal
|
|
// of its own, so `.modal.show.d-block` is unambiguous on this page.
|
|
var modal = page.Locator(".modal.show.d-block");
|
|
await Assertions.Expect(modal).ToBeVisibleAsync();
|
|
await Assertions.Expect(modal.Locator("h5.modal-title")).ToHaveTextAsync("Edit Attribute");
|
|
|
|
// When editing, the Name input is rendered readonly (readonly="@editing").
|
|
await Assertions.Expect(
|
|
modal.Locator("div.col-12:has(label:has-text('Name')) input.form-control"))
|
|
.ToHaveAttributeAsync("readonly", "");
|
|
|
|
// The Value input is label-anchored: its containing div carries the
|
|
// "Value" label. ("Data Source Ref" does not contain "Value", so the
|
|
// substring match resolves uniquely to the Value field.)
|
|
var valueInput = modal.Locator("div.col-12:has(label:has-text('Value')) input.form-control");
|
|
await valueInput.FillAsync("42.5");
|
|
|
|
// Footer button text is "Save" when editing (vs "Add" when adding). Size class
|
|
// sits on the enclosing btn-group-sm since the 2026-08-11 UI sweep.
|
|
await modal.Locator(".modal-footer button.btn-success:has-text('Save')").ClickAsync();
|
|
|
|
// SaveAttribute persists then reloads, dismissing the modal.
|
|
await Assertions.Expect(modal).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
|
|
// The attribute table renders the value in a <td class="small">@effectiveValue</td>
|
|
// cell (for a non-derived template effectiveValue == attr.Value), so the
|
|
// persisted "42.5" must appear in the Val row's value cell. Web-first wait
|
|
// covers the post-save reload.
|
|
await Assertions.Expect(page.Locator("table td.small:has-text('42.5')"))
|
|
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
|
}
|
|
finally
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(id);
|
|
// Defensive sweep by name in case the delete above was a no-op.
|
|
try
|
|
{
|
|
foreach (var leftover in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
|
{
|
|
await CliRunner.DeleteTemplateAsync(leftover);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort — swallow to avoid masking the original test failure.
|
|
}
|
|
}
|
|
}
|
|
|
|
[SkippableFact]
|
|
public async Task DeleteTemplate_WithInstance_IsBlocked()
|
|
{
|
|
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
|
|
|
// Mint a non-deployed instance referencing the fixture's template — that
|
|
// reference is sufficient for TemplateDeletionService to block the delete
|
|
// with the "instance(s) reference it" error.
|
|
var (instId, _) = await _cluster.CreateInstanceAsync();
|
|
try
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/{_cluster.TemplateId}");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Header Delete button (btn-outline-danger btn-sm).
|
|
await page.ClickAsync("button.btn.btn-outline-danger:has-text('Delete')");
|
|
|
|
// Confirm via the global DialogHost modal's danger button (labelled
|
|
// "Delete"). This modal HAS the `fade` class — distinct from the
|
|
// page-local attribute modal.
|
|
var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')");
|
|
await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
|
await confirmBtn.ClickAsync();
|
|
|
|
// The delete fails: DeleteTemplate surfaces result.Error on a toast and
|
|
// does NOT navigate. The error reads
|
|
// "Cannot delete template '{name}': {n} instance(s) reference it (...)".
|
|
await Assertions.Expect(page.Locator(".toast"))
|
|
.ToContainTextAsync("instance(s) reference it", new() { Timeout = 10_000 });
|
|
|
|
// The template still exists — the page stayed on the detail URL because
|
|
// the failed delete did not navigate away.
|
|
await Assertions.Expect(page)
|
|
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex(
|
|
$"/design/templates/{_cluster.TemplateId}$"));
|
|
}
|
|
finally
|
|
{
|
|
// Remove this test's instance so the fixture template is instance-free
|
|
// again; the fixture's DisposeAsync sweeps the template + any leftovers.
|
|
await CliRunner.DeleteInstanceAsync(instId);
|
|
}
|
|
}
|
|
}
|