9e243493fb
Applies the family-wide admin-UI cleanup playbook to the Central UI so the Blazor surfaces stop diverging from the shared kit: buttons are grouped rather than individually sized, long cell values are contained instead of widening tables, and hard-coded colours give way to theme tokens. The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell, which the kit emits as an inline style on the shell root. Being a descendant of <html>, it beat the [data-bs-theme="dark"] override for the entire app, so the dark accent had never rendered. Declaring --accent in site.css :root instead lets both schemes resolve; light is unchanged because the value already matched the kit's light default. Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so that block is deleted here rather than duplicated. Verified byte-identical before removal; the repo now declares no --bs-btn-* anywhere. NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages, SiteCallsReport) were additionally refactored from holding the selected record to holding its id and re-resolving from the current page each render, with the resolve doubling as the visibility gate. A background refresh that drops the row now closes the modal instead of showing a stale snapshot. This is a behaviour change and is called out rather than buried: a full-suite run turned up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack (GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler was disposed between render and click — a window the previous field-held record made structurally impossible. Treat the modal lifecycle here as unreviewed. Build 0/0; suite green apart from that one intermittent failure.
310 lines
15 KiB
C#
310 lines
15 KiB
C#
using Microsoft.Playwright;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
|
|
|
|
[Collection("Playwright")]
|
|
public class SiteCrudTests
|
|
{
|
|
private readonly PlaywrightFixture _fixture;
|
|
|
|
public SiteCrudTests(PlaywrightFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SitesPage_ShowsSiteManagement()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Sites.razor renders the management page as a heading plus site cards
|
|
// (not an HTML table) and an always-present "Add Site" action.
|
|
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
|
|
await Expect(page.Locator("button:has-text('Add Site')")).ToBeVisibleAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddSiteButton_NavigatesToCreatePage()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
await page.ClickAsync("button:has-text('Add Site')");
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites/create");
|
|
var inputCount = await page.Locator("input").CountAsync();
|
|
Assert.True(inputCount >= 2, $"Expected at least 2 inputs, found {inputCount}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePage_BackButton_ReturnsToList()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
await page.ClickAsync("button:has-text('Back')");
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
|
await Expect(page.Locator("h4:has-text('Site Management')")).ToBeVisibleAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePage_CancelButton_ReturnsToList()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
await page.ClickAsync("button:has-text('Cancel')");
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePage_HasNodeSubsections()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await Expect(page.Locator("h6:has-text('Node A')")).ToBeVisibleAsync();
|
|
await Expect(page.Locator("h6:has-text('Node B')")).ToBeVisibleAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreatePage_SaveWithoutName_ShowsError()
|
|
{
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
await page.ClickAsync("button:has-text('Save')");
|
|
|
|
// Should stay on create page with validation error
|
|
Assert.Contains("/admin/sites/create", page.Url);
|
|
await Expect(page.Locator(".text-danger")).ToBeVisibleAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Full create → edit → delete round-trip for a site via the Central UI.
|
|
/// Uses CliRunner for best-effort teardown so no zztest-* site leaks on failure.
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task CreateEditDelete_Site_RoundTrips()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var hex = Guid.NewGuid().ToString("N")[..8];
|
|
var ident = $"zztest-{hex}";
|
|
var name = $"zztest-site-{hex}";
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
try
|
|
{
|
|
// ── CREATE ────────────────────────────────────────────────────────────────
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Fill Identifier — label-anchored input (Node A/B share the same Akka/gRPC
|
|
// placeholder text so we select the Akka inputs by placeholder index below).
|
|
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync(ident);
|
|
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(name);
|
|
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm").FillAsync("e2e");
|
|
|
|
// Node A and Node B share identical placeholder text; select by index.
|
|
// Index 0 = Node A Akka, Index 1 = Node B Akka.
|
|
var akkaInputs = page.Locator("input[placeholder='akka.tcp://scadabridge@host:port/user/site-communication']");
|
|
await akkaInputs.Nth(0).FillAsync("akka.tcp://scadabridge@zz:5000/user/site-communication");
|
|
await akkaInputs.Nth(1).FillAsync("akka.tcp://scadabridge@zz:5000/user/site-communication");
|
|
|
|
// Index 0 = Node A gRPC, Index 1 = Node B gRPC.
|
|
var grpcInputs = page.Locator("input[placeholder='http://host:8083']");
|
|
await grpcInputs.Nth(0).FillAsync("http://zz:8083");
|
|
await grpcInputs.Nth(1).FillAsync("http://zz:8083");
|
|
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
|
|
|
// Wait for Blazor enhanced navigation back to the list page.
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The new site card must be visible.
|
|
var card = page.Locator("div.card", new() { HasText = name });
|
|
await Assertions.Expect(card).ToBeVisibleAsync();
|
|
|
|
// ── EDIT ──────────────────────────────────────────────────────────────────
|
|
// Scope the Edit button to the card to avoid strict-mode violations.
|
|
await card.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites/", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Update the description and save.
|
|
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm").FillAsync("e2e-edited");
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/edit");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Card must still be present after the edit.
|
|
await Assertions.Expect(page.Locator("div.card", new() { HasText = name })).ToBeVisibleAsync();
|
|
|
|
// ── DELETE ────────────────────────────────────────────────────────────────
|
|
// Re-locate the card after navigation; scope kebab+delete to the .dropdown
|
|
// container inside the card to be defensive against strict-mode violations.
|
|
var cardAfterEdit = page.Locator("div.card", new() { HasText = name });
|
|
var cardDropdown = cardAfterEdit.Locator(".dropdown");
|
|
var kebab = cardDropdown.Locator("button[aria-label^='More actions']");
|
|
await kebab.ClickAsync();
|
|
|
|
// Click Delete in the now-open dropdown — scoped to the .dropdown container.
|
|
var deleteBtn = cardDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger");
|
|
await deleteBtn.ClickAsync();
|
|
|
|
// Confirm the global danger dialog.
|
|
await Assertions.Expect(page.Locator(".modal-footer .btn-danger")).ToBeVisibleAsync();
|
|
await page.ClickAsync(".modal-footer .btn-danger");
|
|
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// The card must be gone.
|
|
await Assertions.Expect(page.Locator("div.card", new() { HasText = name }))
|
|
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort teardown: delete the site via CLI in case the UI path failed
|
|
// mid-way. The happy path already deleted it via the UI, so ResolveSiteIdAsync
|
|
// will throw (no matching site) and the inner catch swallows it.
|
|
try
|
|
{
|
|
await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(ident));
|
|
}
|
|
catch
|
|
{
|
|
// Site already deleted (happy path) or cluster unreachable — ignore.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creating a site whose Identifier collides with the always-present seeded
|
|
/// <c>site-a</c> must fail at save and persist nothing. SiteForm.SaveSite()
|
|
/// catches the duplicate DbUpdateException → <c>_formError = "Save failed: …"</c>
|
|
/// (a raw EF message) and stays on /create. The Name is deliberately distinct so
|
|
/// only the SiteIdentifier unique index can trip (not the Name unique index).
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task Create_DuplicateIdentifier_ShowsSaveFailedError()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
// Confirm the collision target exists (site-a is a core cluster seed; throws if absent).
|
|
await CliRunner.ResolveSiteIdAsync("site-a");
|
|
|
|
// Distinct name so the failure can only be the SiteIdentifier unique index.
|
|
var distinctName = $"zztest-dup-{Guid.NewGuid():N}"[..16];
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// Identifier collides with the seeded site-a; Name is distinct. Node addresses blank.
|
|
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync("site-a");
|
|
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(distinctName);
|
|
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
|
|
|
// The inline error surface must report the failed save and we must stay on /create.
|
|
// The full message is a raw DbUpdateException — assert only the "Save failed" prefix.
|
|
await Expect(page.Locator("div.text-danger.small.mt-2")).ToContainTextAsync("Save failed");
|
|
await Assertions.Expect(page)
|
|
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/sites/create"));
|
|
|
|
// No teardown: the SiteIdentifier unique index guarantees the create persisted nothing.
|
|
// A spuriously-persisted row would carry identifier "site-a" (not distinctName), and that
|
|
// cannot be auto-deleted without destroying the real seed — so there is nothing safe to sweep.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Editing a site and clicking Cancel must discard the change. SiteForm's Cancel
|
|
/// (like Back) calls GoBack() → /admin/sites with NO dirty-check and NO persistence,
|
|
/// so re-opening the edit must show the ORIGINAL (empty) Description.
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task EditCancel_DiscardsChanges()
|
|
{
|
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
|
|
|
var hex = Guid.NewGuid().ToString("N")[..8];
|
|
var ident = $"zztest-{hex}";
|
|
var name = $"zztest-site-{hex}";
|
|
|
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
|
|
|
try
|
|
{
|
|
// ── CREATE (mirror the round-trip test's create steps) ──────────────────────
|
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync(ident);
|
|
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(name);
|
|
|
|
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// ── EDIT → change Description → CANCEL ──────────────────────────────────────
|
|
var card = page.Locator("div.card", new() { HasText = name });
|
|
await Assertions.Expect(card).ToBeVisibleAsync();
|
|
await card.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/edit", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm")
|
|
.FillAsync("zztest-CANCELLED-EDIT");
|
|
|
|
await page.ClickAsync("button:has-text('Cancel')");
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/edit");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
// ── RE-OPEN EDIT → Description must be the ORIGINAL (empty) ──────────────────
|
|
var cardAfterCancel = page.Locator("div.card", new() { HasText = name });
|
|
await Assertions.Expect(cardAfterCancel).ToBeVisibleAsync();
|
|
await cardAfterCancel.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
|
|
|
await PlaywrightFixture.WaitForPathAsync(page, "/edit", excludePath: "/create");
|
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
|
|
|
var descInput = page.Locator("label:has-text('Description') + input.form-control.form-control-sm");
|
|
await Expect(descInput).ToHaveValueAsync("");
|
|
}
|
|
finally
|
|
{
|
|
// Best-effort teardown: delete the site via CLI.
|
|
try
|
|
{
|
|
await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(ident));
|
|
}
|
|
catch
|
|
{
|
|
// Already gone or cluster unreachable — ignore.
|
|
}
|
|
}
|
|
}
|
|
|
|
private static ILocatorAssertions Expect(ILocator locator) =>
|
|
Assertions.Expect(locator);
|
|
}
|