Move all CRUD create/edit forms from inline on list pages to dedicated form pages with back-button navigation and post-save redirect. Add Playwright Docker container (browser server on port 3000) with 25 passing E2E tests covering login, navigation, and site CRUD workflows. Add POST /auth/token endpoint for clean JWT retrieval.
109 lines
4.3 KiB
C#
109 lines
4.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 test user.
|
|
/// 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()
|
|
{
|
|
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.
|
|
// Use redirect: 'follow' so the browser follows the 302 and the cookie is stored.
|
|
var finalUrl = await page.EvaluateAsync<string>(@"
|
|
async () => {
|
|
const resp = await fetch('/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: 'username=' + encodeURIComponent('" + TestUsername + @"')
|
|
+ '&password=' + encodeURIComponent('" + TestPassword + @"'),
|
|
redirect: 'follow'
|
|
});
|
|
return resp.url;
|
|
}
|
|
");
|
|
|
|
// The fetch followed the redirect. If it ended on /login, auth failed.
|
|
if (finalUrl.Contains("/login"))
|
|
{
|
|
throw new InvalidOperationException($"Login failed — 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 });
|
|
}
|
|
}
|
|
|
|
[CollectionDefinition("Playwright")]
|
|
public class PlaywrightCollection : ICollectionFixture<PlaywrightFixture>;
|