test(e2e): cover Site create/edit/delete round-trip

Adds CreateEditDelete_Site_RoundTrips [SkippableFact] to SiteCrudTests.
Exercises the full create → edit → delete UI flow against the live cluster,
with CliRunner best-effort teardown so no zztest-* sites leak on mid-test failure.
This commit is contained in:
Joseph Doherty
2026-06-05 10:28:40 -04:00
parent 271f70b1d2
commit 3998a6126f
@@ -1,4 +1,5 @@
using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
@@ -91,6 +92,107 @@ public class SiteCrudTests
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.btn-sm: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.btn-sm: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 the kebab to avoid strict mode.
var cardAfterEdit = page.Locator("div.card", new() { HasText = name });
var kebab = cardAfterEdit.Locator("button[aria-label^='More actions']");
await kebab.ClickAsync();
// Click Delete in the now-open dropdown — scoped to the card.
var deleteBtn = cardAfterEdit.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.
}
}
}
private static ILocatorAssertions Expect(ILocator locator) =>
Assertions.Expect(locator);
}