test(e2e): harden LDAP teardown + tighten nav/health selectors (review fixes)
This commit is contained in:
+84
-44
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Playwright;
|
using Microsoft.Playwright;
|
||||||
|
using System.Text.Json;
|
||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin;
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Admin;
|
||||||
@@ -22,65 +23,104 @@ public class LdapMappingCrudTests
|
|||||||
{
|
{
|
||||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||||
|
|
||||||
// Keep the group name short and unique to avoid collisions with other test runs.
|
// Truncated to 18 chars to stay within the form field's maximum input length
|
||||||
|
// while still being unique (the zztest-grp- prefix + 7 hex chars from the GUID).
|
||||||
var group = $"zztest-grp-{Guid.NewGuid():N}"[..18];
|
var group = $"zztest-grp-{Guid.NewGuid():N}"[..18];
|
||||||
|
|
||||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||||
|
|
||||||
// ── CREATE ────────────────────────────────────────────────────────────────
|
try
|
||||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
|
{
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
// ── CREATE ────────────────────────────────────────────────────────────────
|
||||||
|
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
|
||||||
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
// The LDAP Group Name label has no `for=` attribute so GetByLabel does not
|
// The LDAP Group Name label has no `for=` attribute so GetByLabel does not
|
||||||
// work. Locate the input that immediately follows the label text instead.
|
// work. Locate the input that immediately follows the label text instead.
|
||||||
await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm").FillAsync(group);
|
await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm").FillAsync(group);
|
||||||
await page.SelectOptionAsync(".form-select.form-select-sm", "Designer");
|
// Scope the role select to the div.mb-2 that owns the "Role" label so a
|
||||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
// second select (Site Scope) on the edit page cannot cause a strict-mode
|
||||||
|
// violation. The LdapMappingForm.razor uses Blazor @bind, not <form>,
|
||||||
|
// so the "Role" label's parent div.mb-2 is the tightest stable scope.
|
||||||
|
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Designer");
|
||||||
|
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||||
|
|
||||||
// Wait for Blazor enhanced navigation back to the list page.
|
// Wait for Blazor enhanced navigation back to the list page.
|
||||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/create");
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/create");
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
// The new row must be visible on the list.
|
// The new row must be visible on the list.
|
||||||
var newRow = page.Locator("tr", new() { HasText = group });
|
var newRow = page.Locator("tr", new() { HasText = group });
|
||||||
await Assertions.Expect(newRow).ToBeVisibleAsync();
|
await Assertions.Expect(newRow).ToBeVisibleAsync();
|
||||||
|
|
||||||
// ── EDIT ──────────────────────────────────────────────────────────────────
|
// ── EDIT ──────────────────────────────────────────────────────────────────
|
||||||
// Click the Edit button within that row.
|
// Click the Edit button within that row.
|
||||||
await newRow.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
await newRow.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
||||||
|
|
||||||
await PlaywrightFixture.WaitForPathAsync(page, "/edit");
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings/", excludePath: "/create");
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
// Change the role to Viewer and save.
|
// Change the role to Viewer and save.
|
||||||
await page.SelectOptionAsync(".form-select.form-select-sm", "Viewer");
|
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Viewer");
|
||||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||||
|
|
||||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/edit");
|
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/edit");
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
// Row must still be present after the edit.
|
// Row must still be present after the edit.
|
||||||
var editedRow = page.Locator("tr", new() { HasText = group });
|
var editedRow = page.Locator("tr", new() { HasText = group });
|
||||||
await Assertions.Expect(editedRow).ToBeVisibleAsync();
|
await Assertions.Expect(editedRow).ToBeVisibleAsync();
|
||||||
|
|
||||||
// ── DELETE (no confirmation dialog) ───────────────────────────────────────
|
// ── DELETE (no confirmation dialog) ───────────────────────────────────────
|
||||||
// Scope all dropdown interactions to the row's .dropdown container so we
|
// Scope all dropdown interactions to the row's .dropdown container so we
|
||||||
// never accidentally match a Delete button from another row's menu.
|
// never accidentally match a Delete button from another row's menu.
|
||||||
var rowDropdown = editedRow.Locator(".dropdown");
|
var rowDropdown = editedRow.Locator(".dropdown");
|
||||||
var kebab = rowDropdown.Locator("button[aria-label^='More actions']");
|
var kebab = rowDropdown.Locator("button[aria-label^='More actions']");
|
||||||
await kebab.ClickAsync();
|
await kebab.ClickAsync();
|
||||||
|
|
||||||
// Click Delete in the now-open dropdown within this row — no confirm dialog.
|
// Click Delete in the now-open dropdown within this row — no confirm dialog.
|
||||||
var deleteBtn = rowDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger");
|
var deleteBtn = rowDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger");
|
||||||
await deleteBtn.ClickAsync();
|
await deleteBtn.ClickAsync();
|
||||||
|
|
||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
// The row must be gone.
|
// The row must be gone.
|
||||||
await Assertions.Expect(page.Locator("tr", new() { HasText = group }))
|
await Assertions.Expect(page.Locator("tr", new() { HasText = group }))
|
||||||
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
.ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||||
|
}
|
||||||
// TODO: best-effort cleanup if a mid-test failure left a zztest-grp-* row.
|
finally
|
||||||
// The happy path deletes via the UI above; no CLI cleanup needed for the passing case.
|
{
|
||||||
|
// Best-effort safety net: if the test failed mid-way and the mapping was
|
||||||
|
// not deleted by the UI, clean it up via the CLI so it doesn't leak.
|
||||||
|
// Uses `security role-mapping list` + `security role-mapping delete --id`
|
||||||
|
// (CLI verbs from SecurityCommands.cs BuildRoleMapping). All exceptions
|
||||||
|
// are swallowed — teardown must never mask the test's own failure.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = await CliRunner.RunJsonAsync("security", "role-mapping", "list");
|
||||||
|
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var mapping in doc.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (mapping.TryGetProperty("ldapGroupName", out var grpProp)
|
||||||
|
&& grpProp.ValueKind == JsonValueKind.String
|
||||||
|
&& string.Equals(grpProp.GetString(), group, StringComparison.Ordinal)
|
||||||
|
&& mapping.TryGetProperty("id", out var idProp)
|
||||||
|
&& idProp.TryGetInt32(out var mappingId))
|
||||||
|
{
|
||||||
|
await CliRunner.RunAsync(
|
||||||
|
"security", "role-mapping", "delete",
|
||||||
|
"--id", mappingId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort — the mapping may already be deleted (happy path) or
|
||||||
|
// the cluster may be unreachable; never fail teardown.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-12
@@ -76,19 +76,26 @@ public class HealthDashboardTests
|
|||||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
|
||||||
// ── Notification Outbox tiles (no data-test; inlined in Health.razor as
|
// ── Notification Outbox tiles (no data-test; inlined in Health.razor as
|
||||||
// plain div.card elements, not buttons — use that to disambiguate from
|
// plain div.card elements under the "Notification Outbox" h6 heading).
|
||||||
// the SiteCallKpiTiles which render their cards as <button class="card">)
|
// Scope all three cards to the div.row that immediately follows the flex
|
||||||
// The Outbox section renders under the "Notification Outbox" h6 heading.
|
// container holding the h6 — prevents false matches if a second section
|
||||||
// Scope by the exact small.text-muted label to pinpoint each tile's h3.
|
// later grows cards with the same label text ("Stuck", "Parked").
|
||||||
|
//
|
||||||
|
// Health.razor structure:
|
||||||
|
// <div class="d-flex ...">
|
||||||
|
// <h6 class="text-muted mb-0">Notification Outbox</h6> ← anchor
|
||||||
|
// <a ...>View details →</a>
|
||||||
|
// </div>
|
||||||
|
// <div class="row g-3 mb-3"> ← outboxSection (+sibling)
|
||||||
|
// <div class="col-..."><div class="card">Queue Depth</div></div>
|
||||||
|
// <div class="col-..."><div class="card">Stuck</div></div>
|
||||||
|
// <div class="col-..."><div class="card">Parked</div></div>
|
||||||
|
// </div>
|
||||||
|
var outboxSection = page.Locator("div.d-flex:has(h6:has-text('Notification Outbox')) + div.row");
|
||||||
|
|
||||||
var queueDepthH3 = page.Locator("div.card", new() { HasText = "Queue Depth" }).Locator("h3");
|
var queueDepthH3 = outboxSection.Locator("div.card", new() { HasText = "Queue Depth" }).Locator("h3");
|
||||||
// "Stuck" and "Parked" labels appear in both the Outbox section (div.card)
|
var outboxStuckH3 = outboxSection.Locator("div.card", new() { HasText = "Stuck" }).Locator("h3");
|
||||||
// and the Site Call tiles section (button.card). Discriminate on the element
|
var outboxParkedH3 = outboxSection.Locator("div.card", new() { HasText = "Parked" }).Locator("h3");
|
||||||
// type: the Outbox tiles are <div class="card">, the Site Call tiles are
|
|
||||||
// <button class="card">. Using `div.card` (not `button.card`) ensures we
|
|
||||||
// select the Notification Outbox cards only.
|
|
||||||
var outboxStuckH3 = page.Locator("div.card", new() { HasText = "Stuck" }).Locator("h3");
|
|
||||||
var outboxParkedH3 = page.Locator("div.card", new() { HasText = "Parked" }).Locator("h3");
|
|
||||||
|
|
||||||
await AssertTileResolvedAsync(queueDepthH3, "Outbox Queue Depth");
|
await AssertTileResolvedAsync(queueDepthH3, "Outbox Queue Depth");
|
||||||
await AssertTileResolvedAsync(outboxStuckH3, "Outbox Stuck");
|
await AssertTileResolvedAsync(outboxStuckH3, "Outbox Stuck");
|
||||||
|
|||||||
@@ -106,10 +106,11 @@ public class NavigationTests
|
|||||||
|
|
||||||
// Verify the destination page actually rendered its heading (catches 500s
|
// Verify the destination page actually rendered its heading (catches 500s
|
||||||
// and blank renders that a URL-only check would miss).
|
// and blank renders that a URL-only check would miss).
|
||||||
|
// Every mapped route renders its heading as an <h4> — tightened from the
|
||||||
|
// broader "h1, h4, h5" to prevent strict-mode violations if multiple
|
||||||
|
// heading elements match.
|
||||||
var expectedHeading = RouteHeadings[expectedPath];
|
var expectedHeading = RouteHeadings[expectedPath];
|
||||||
await Assertions.Expect(
|
await Assertions.Expect(page.Locator("h4", new() { HasText = expectedHeading })).ToBeVisibleAsync();
|
||||||
page.Locator("h1, h4, h5", new() { HasText = expectedHeading })
|
|
||||||
).ToBeVisibleAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user