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(); } /// /// 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. /// [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 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. } } } private static ILocatorAssertions Expect(ILocator locator) => Assertions.Expect(locator); }