From 1536cdb8841c651b23df534667e7621637533e82 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 6 Jun 2026 14:55:18 -0400 Subject: [PATCH] test(playwright): add ExternalSystem CRUD + validation coverage (Wave 3) --- .../Design/ExternalSystemCrudTests.cs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs new file mode 100644 index 00000000..d870f87b --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/ExternalSystemCrudTests.cs @@ -0,0 +1,104 @@ +using Microsoft.Playwright; +using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design; + +[Collection("Playwright")] +public class ExternalSystemCrudTests +{ + private readonly PlaywrightFixture _pw; + + public ExternalSystemCrudTests(PlaywrightFixture pw) + { + _pw = pw; + } + + /// + /// Full create → card → delete round-trip for an external system via the Central UI. + /// Uses CliRunner for best-effort teardown so no zztest-extsys-* definition leaks on + /// failure (the happy path already deletes via the UI). + /// + [SkippableFact] + public async Task Create_Delete_RoundTrips() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var name = CliRunner.UniqueName("extsys"); + + var page = await _pw.NewAuthenticatedPageAsync(); + + try + { + // ── CREATE ──────────────────────────────────────────────────────────────── + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/external-systems/create"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await Assertions.Expect(page.Locator("h4:has-text('Add External System')")).ToBeVisibleAsync(); + + // Name and Endpoint URL are both input[type=text].form-control. Per + // ExternalSystemForm.razor, Name is the first text input and Endpoint URL + // is the second — select by order. + var textInputs = page.Locator("input[type=text].form-control"); + await textInputs.Nth(0).FillAsync(name); + await textInputs.Nth(1).FillAsync("https://example.invalid/api"); + + await page.Locator("button.btn-success:has-text('Save')").ClickAsync(); + + // Save redirects back to the list (no toast). + await PlaywrightFixture.WaitForPathAsync(page, "/design/external-systems", excludePath: "/create"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The new card must be present (and unique) before we act on it. + var card = page.Locator("div.card").Filter(new() { HasText = name }); + await Assertions.Expect(card).ToHaveCountAsync(1, new() { Timeout = 10_000 }); + + // ── DELETE ──────────────────────────────────────────────────────────────── + // Scope the kebab + delete item to the card's .dropdown for strict-mode safety. + var cardDropdown = card.Locator(".dropdown"); + await cardDropdown.Locator("button[aria-label^='More actions']").ClickAsync(); + + await cardDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger").ClickAsync(); + + // Confirm the delete dialog. + await Assertions.Expect(page.Locator(".modal-title:has-text('Delete External System')")).ToBeVisibleAsync(); + await page.Locator(".modal-footer .btn-danger").ClickAsync(); + + // Single web-first assertion on the success toast — toasts auto-dismiss at 5s, + // so we do NOT chase .toast-body in a second sequential check. + await Assertions.Expect(page.Locator(".toast", new() { HasText = "Deleted." })) + .ToHaveCountAsync(1, new() { Timeout = 15_000 }); + + // The card must be gone. + await Assertions.Expect(card).ToHaveCountAsync(0, new() { Timeout = 10_000 }); + } + finally + { + // Best-effort teardown: the happy path deletes via the UI, so this finds + // nothing; it only fires if the UI path failed mid-way. + foreach (var id in await CliRunner.ListExternalSystemIdsByNamePrefixAsync(name)) + await CliRunner.DeleteExternalSystemAsync(id); + } + } + + /// + /// Saving the create form with both fields blank surfaces the inline validation + /// error and keeps the user on the create page. Mutates nothing. + /// + [SkippableFact] + public async Task CreateForm_EmptyFields_ShowsInlineError() + { + Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason); + + var page = await _pw.NewAuthenticatedPageAsync(); + + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/external-systems/create"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await page.Locator("button.btn-success:has-text('Save')").ClickAsync(); + + await Assertions.Expect(page.Locator("div.text-danger.small:has-text('Name and URL required.')")) + .ToBeVisibleAsync(); + await Assertions.Expect(page) + .ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/create")); + } +}