using Microsoft.Playwright; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests; /// /// Shared fixture that manages the Playwright browser connection. /// Creates a single browser connection per test collection, reused across all tests. /// Requires the Playwright Docker container running at ws://localhost:3000. /// public class PlaywrightFixture : IAsyncLifetime { /// /// Playwright Server WebSocket endpoint (Docker container on host port 3000). /// private const string PlaywrightWsEndpoint = "ws://localhost:3000"; /// /// Central UI base URL as seen from inside the Docker network. /// The browser runs in the Playwright container, so it uses the Docker hostname. /// public const string BaseUrl = "http://scadabridge-traefik"; /// Test LDAP credentials (multi-role user with Admin + Design + Deployment). public const string TestUsername = "multi-role"; public const string TestPassword = "password"; public IPlaywright Playwright { get; private set; } = null!; public IBrowser Browser { get; private set; } = null!; /// /// Live browser contexts created by , oldest first. /// Capped to so finished tests' contexts are /// closed eagerly rather than leaking for the whole run (see ). /// private readonly List _contexts = new(); /// /// Maximum number of browser contexts kept open at once. Each context holds a live /// Blazor Server SignalR circuit on the Central UI; the full suite runs serially within /// the Playwright collection, so contexts from already-finished tests can be closed /// safely. Leaving every context open accumulated ~one circuit per test and slowed late /// tests into navigation/visibility timeouts. A small cap (covering any single test that /// opens more than one page) keeps server-side circuit pressure flat across the run. /// private const int MaxRetainedContexts = 4; public async Task InitializeAsync() { Playwright = await Microsoft.Playwright.Playwright.CreateAsync(); Browser = await Playwright.Chromium.ConnectAsync(PlaywrightWsEndpoint); } public async Task DisposeAsync() { await Browser.CloseAsync(); Playwright.Dispose(); } /// /// Create a new browser context and page. Each test gets an isolated session. Contexts /// from already-finished tests are closed eagerly once more than /// are open, to bound the number of concurrent Blazor /// Server circuits the run holds on the Central UI. /// public async Task NewPageAsync() { var context = await Browser.NewContextAsync(); var page = await context.NewPageAsync(); List toClose = new(); lock (_contexts) { _contexts.Add(context); int excess = _contexts.Count - MaxRetainedContexts; if (excess > 0) { toClose = _contexts.GetRange(0, excess); _contexts.RemoveRange(0, excess); } } foreach (var old in toClose) { // Best-effort: a finished test's context may already be gone; never fail a test // (or the next page creation) on teardown of a stale context. try { await old.CloseAsync(); } catch { /* best-effort */ } } return page; } /// /// Create a new page and log in with the default multi-role test user. /// public Task NewAuthenticatedPageAsync() => NewAuthenticatedPageAsync(TestUsername, TestPassword); /// /// Create a new page and log in with specific credentials. /// Uses JavaScript fetch() to POST to /auth/login from within the browser, /// which sets the auth cookie in the browser context. Then navigates to the dashboard. /// public async Task NewAuthenticatedPageAsync(string username, string password) { var page = await NewPageAsync(); // Navigate to the login page first to establish the origin await page.GotoAsync($"{BaseUrl}/login"); await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); // POST to /auth/login via fetch() inside the browser. // This sets the auth cookie in the browser context automatically. var finalUrl = await page.EvaluateAsync(@" async ([u, p]) => { const resp = await fetch('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'username=' + encodeURIComponent(u) + '&password=' + encodeURIComponent(p), redirect: 'follow' }); return resp.url; } ", new object[] { username, password }); if (finalUrl.Contains("/login")) { throw new InvalidOperationException($"Login failed for '{username}' — redirected back to login: {finalUrl}"); } // Navigate to the dashboard — cookie authenticates us await page.GotoAsync(BaseUrl); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); return page; } /// /// Wait for Blazor enhanced navigation to update the URL path. /// Blazor Server uses SignalR for client-side navigation (no full page reload), /// so standard WaitForURLAsync times out. This polls window.location instead. /// public static async Task WaitForPathAsync(IPage page, string path, string? excludePath = null, int timeoutMs = 10000) { var js = excludePath != null ? $"() => window.location.pathname.includes('{path}') && !window.location.pathname.includes('{excludePath}')" : $"() => window.location.pathname.includes('{path}')"; await page.WaitForFunctionAsync(js, null, new() { Timeout = timeoutMs }); } /// /// Expand every collapsed sidebar nav section so its links are visible and /// clickable. The ZB.MOM.WW.Theme kit renders each section as a native /// <details class="rail-section"> whose <summary /// class="rail-eyebrow-toggle"> carries an aria-expanded attribute /// (rendered from the section's open state at SSR time and kept in sync by the /// kit's nav-state.js). Sections are expanded by default, but a section the user /// previously collapsed is restored collapsed from localStorage — its links stay /// in the DOM but are hidden until the section is expanded. Call this after /// authenticating, before interacting with sectioned nav links. /// public static async Task ExpandAllNavSectionsAsync(IPage page) { var toggles = page.Locator("summary.rail-eyebrow-toggle"); int count = await toggles.CountAsync(); for (int i = 0; i < count; i++) { var toggle = toggles.Nth(i); if (await toggle.GetAttributeAsync("aria-expanded") == "false") { await toggle.ClickAsync(); // Wait for the section's own state to flip (the kit's nav-state.js // mirrors the native
onto aria-expanded) before // moving to the next section. await Assertions.Expect(toggle).ToHaveAttributeAsync("aria-expanded", "true"); } } } } [CollectionDefinition("Playwright")] public class PlaywrightCollection : ICollectionFixture;