Files
scadalink-design/tests/ScadaLink.CentralUI.PlaywrightTests/PlaywrightFixture.cs
Joseph Doherty 86ee7bd1a8 feat(centralui): collapsible sidebar nav sections
Make the seven sidebar section groups (Admin, Design, Deployment,
Notifications, Site Calls, Monitoring, Audit) collapsible. New NavSection
component renders a header toggle button (chevron) and reveals its items
only while expanded; NavMenu owns the expanded-section set.

Behaviour: sections are collapsed by default; state persists in the
`scadabridge_nav` cookie (written/read via the new nav-state.js JS interop,
mirroring treeview-storage.js) so it survives reloads and reconnects;
navigating into a section auto-expands it and remembers it. The Dashboard
item stays sectionless and always visible.

Tests: NavMenu bUnit tests expand sections before asserting items and add
collapsed-by-default / toggle / cookie-persistence cases; Playwright nav
tests expand sections before clicking links; new NavCollapseTests covers
the feature E2E. Build 0 warnings; bUnit 545 passed; Playwright nav suite
green (the unrelated AuditGridColumnTests resize-reload case remains
pre-existing flaky — an un-awaited save race in that test).
2026-05-22 07:36:57 -04:00

134 lines
5.3 KiB
C#

using Microsoft.Playwright;
namespace ScadaLink.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://scadalink-traefik";
/// <summary>Test LDAP credentials (multi-role user with Admin + Design + Deployment).</summary>
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!;
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();
}
/// <summary>
/// Create a new browser context and page. Each test gets an isolated session.
/// </summary>
public async Task<IPage> NewPageAsync()
{
var context = await Browser.NewContextAsync();
return await context.NewPageAsync();
}
/// <summary>
/// Create a new page and log in with the default multi-role test user.
/// </summary>
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>
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 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;
}
/// <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>
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. Nav sections are collapsed by
/// default, so a section's links are not in the DOM until it is expanded.
/// Call this after authenticating, before interacting with sectioned nav links.
/// </summary>
public static async Task ExpandAllNavSectionsAsync(IPage page)
{
var toggles = page.Locator("button.nav-section-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 toggle's own state to flip so the Blazor
// re-render has landed before moving to the next section.
await Assertions.Expect(toggle).ToHaveAttributeAsync("aria-expanded", "true");
}
}
}
}
[CollectionDefinition("Playwright")]
public class PlaywrightCollection : ICollectionFixture<PlaywrightFixture>;