Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PlaywrightFixture.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

205 lines
9.5 KiB
C#

using Microsoft.Playwright;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests;
/// <summary>
/// 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.
/// </summary>
public class PlaywrightFixture : IAsyncLifetime
{
/// <summary>
/// Playwright Server WebSocket endpoint (Docker container on host port 3000).
/// </summary>
private const string PlaywrightWsEndpoint = "ws://localhost:3000";
/// <summary>
/// Central UI base URL as seen from inside the Docker network.
/// The browser runs in the Playwright container, so it uses the Docker hostname.
/// </summary>
public const string BaseUrl = "http://scadabridge-traefik";
/// <summary>Test LDAP credentials (multi-role user with Admin + Design + Deployment).</summary>
public const string TestUsername = "multi-role";
public const string TestPassword = "password";
/// <summary>The Playwright driver instance created during initialization.</summary>
public IPlaywright Playwright { get; private set; } = null!;
/// <summary>The browser connected to the Playwright server.</summary>
public IBrowser Browser { get; private set; } = null!;
/// <summary>
/// Live browser contexts created by <see cref="NewPageAsync"/>, oldest first.
/// Capped to <see cref="MaxRetainedContexts"/> so finished tests' contexts are
/// closed eagerly rather than leaking for the whole run (see <see cref="NewPageAsync"/>).
/// </summary>
private readonly List<IBrowserContext> _contexts = new();
/// <summary>
/// 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 <c>Playwright</c> 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.
/// </summary>
private const int MaxRetainedContexts = 4;
/// <summary>Creates the Playwright driver and connects to the remote Chromium endpoint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
Browser = await Playwright.Chromium.ConnectAsync(PlaywrightWsEndpoint);
}
/// <summary>Closes the browser connection and disposes the Playwright driver.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
await Browser.CloseAsync();
Playwright.Dispose();
}
/// <summary>
/// Create a new browser context and page. Each test gets an isolated session. Contexts
/// from already-finished tests are closed eagerly once more than
/// <see cref="MaxRetainedContexts"/> are open, to bound the number of concurrent Blazor
/// Server circuits the run holds on the Central UI.
/// </summary>
/// <returns>The new page, which belongs to its own isolated browser context.</returns>
public async Task<IPage> NewPageAsync()
{
var context = await Browser.NewContextAsync();
var page = await context.NewPageAsync();
List<IBrowserContext> 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;
}
/// <summary>
/// Create a new page and log in with the default multi-role test user.
/// </summary>
/// <returns>A page that is authenticated as the default test user.</returns>
public Task<IPage> NewAuthenticatedPageAsync() =>
NewAuthenticatedPageAsync(TestUsername, TestPassword);
/// <summary>
/// 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.
/// </summary>
/// <param name="username">The LDAP user name to log in with.</param>
/// <param name="password">The password for <paramref name="username"/>.</param>
/// <returns>A page that is authenticated as the given user.</returns>
public async Task<IPage> 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<string>(@"
async ([u, p]) => {
const token = document.querySelector('input[name=__RequestVerificationToken]');
if (!token?.value) throw new Error('Login form did not render an antiforgery token.');
const body = new URLSearchParams({ username: u, password: p });
body.set(token.name, token.value);
const resp = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
redirect: 'follow'
});
if (!resp.ok) throw new Error('Login returned HTTP ' + resp.status);
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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="page">The page whose location is polled.</param>
/// <param name="path">A path fragment the location must contain.</param>
/// <param name="excludePath">An optional path fragment the location must not contain.</param>
/// <param name="timeoutMs">The maximum time to wait, in milliseconds.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
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 });
}
/// <summary>
/// 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
/// <c>&lt;details class="rail-section"&gt;</c> whose <c>&lt;summary
/// class="rail-eyebrow-toggle"&gt;</c> carries an <c>aria-expanded</c> 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.
/// </summary>
/// <param name="page">The authenticated page whose navigation sections are expanded.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
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 <details open> onto aria-expanded) before
// moving to the next section.
await Assertions.Expect(toggle).ToHaveAttributeAsync("aria-expanded", "true");
}
}
}
}
[CollectionDefinition("Playwright")]
public class PlaywrightCollection : ICollectionFixture<PlaywrightFixture>;