From d78741cfdf9d703620dc0fbbd7f2bd1c0246db39 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Apr 2026 20:55:57 -0400 Subject: [PATCH] =?UTF-8?q?Admin.E2ETests=20scaffolding=20=E2=80=94=20Play?= =?UTF-8?q?wright=20+=20Kestrel=20+=20InMemory=20DB=20+=20test=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the E2E infrastructure filed against task #199 (UnsTab drag-drop Playwright smoke). The Blazor Server interactive-render assertion through a test-owned pipeline needs a dedicated diagnosis pass — filed as task #242 — but the Playwright harness lands here so that follow-up starts from a known-good scaffolding rather than setting up the project from scratch. ## New project tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests - AdminWebAppFactory — boots the Admin pipeline with Kestrel on a free loopback port, swaps the SQL DbContext for EF Core InMemory, replaces the LDAP cookie auth with TestAuthHandler, mirrors the Razor-components/auth/antiforgery pipeline, and seeds a cluster + draft generation with areas warsaw / berlin and a line-a1 in warsaw. Not a WebApplicationFactory because WAF's TestServer transport doesn't coexist cleanly with Kestrel-on-a-real-port, which Playwright needs. - TestAuthHandler — stamps every request with a FleetAdmin claim so tests hit authenticated routes without the LDAP bind. - PlaywrightFixture — one Chromium launch shared across tests; throws PlaywrightBrowserMissingException when the binary isn't installed so tests can Assert.Skip rather than fail hard. - UnsTabDragDropE2ETests.Admin_host_serves_HTTP_via_Playwright_scaffolding — proves the full stack comes up: Kestrel bind, InMemory DbContext, test auth, Playwright navigation, Razor route pipeline responds with HTML < 500. One passing test. ## Prerequisite Chromium must be installed locally: pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium Absent the browser, the suite Assert.Skip's cleanly — CI without the install step still reports green. Once installed, `dotnet test` runs the scaffolding smoke in ~12s. ## Follow-up (task #242) Diagnose why `/clusters/{id}/draft/{gen}` → UNS-tab click → drag-drop flow times out under the test-owned Program.cs replica. Candidate causes: route-ordering difference, missing SignalR hub mapping timing, JS interop asset differences, culture middleware. Once the interactive circuit boots, add: - happy-path drag-drop assertion (source row → target area → Confirm → assert re-parent) - 409 conflict variant (preview → external DB mutation → Confirm → assert red-header modal) --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../AdminWebAppFactory.cs | 130 ++++++++++++++++++ .../PlaywrightFixture.cs | 44 ++++++ .../TestAuthHandler.cs | 34 +++++ .../UnsTabDragDropE2ETests.cs | 74 ++++++++++ .../ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj | 34 +++++ 6 files changed, 317 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 2c304cb..25ec612 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -36,6 +36,7 @@ + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs new file mode 100644 index 0000000..cccb703 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs @@ -0,0 +1,130 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests; + +/// +/// Stands up the Admin Blazor Server host on a free TCP port with the live SQL Server +/// context swapped for an EF Core InMemory DbContext + the LDAP cookie auth swapped for +/// . Playwright connects to . +/// InMemory is sufficient because UnsService's drag-drop path exercises EF operations, +/// not raw SQL. +/// +/// +/// We deliberately build a directly rather than going through +/// WebApplicationFactory<Program> — the factory's TestServer transport doesn't +/// coexist cleanly with Kestrel-on-a-real-port, and Playwright needs a real loopback HTTP +/// endpoint to hit. This mirrors the Program.cs entry-points for everything else. +/// +public sealed class AdminWebAppFactory : IAsyncDisposable +{ + private WebApplication? _app; + + public string BaseUrl { get; private set; } = ""; + public long SeededGenerationId { get; private set; } + public string SeededClusterId { get; } = "e2e-cluster"; + + public async Task StartAsync() + { + var port = GetFreeTcpPort(); + BaseUrl = $"http://127.0.0.1:{port}"; + + var builder = WebApplication.CreateBuilder(Array.Empty()); + builder.WebHost.UseUrls(BaseUrl); + + // --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test + // auth swaps instead of SQL Server + LDAP cookie auth. + builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddSignalR(); + builder.Services.AddAntiforgery(); + + builder.Services.AddAuthentication(TestAuthHandler.SchemeName) + .AddScheme(TestAuthHandler.SchemeName, _ => { }); + builder.Services.AddAuthorizationBuilder() + .AddPolicy("CanEdit", p => p.RequireRole(Admin.Services.AdminRoles.ConfigEditor, Admin.Services.AdminRoles.FleetAdmin)) + .AddPolicy("CanPublish", p => p.RequireRole(Admin.Services.AdminRoles.FleetAdmin)); + builder.Services.AddCascadingAuthenticationState(); + + builder.Services.AddDbContext(opt => + opt.UseInMemoryDatabase($"e2e-{Guid.NewGuid():N}")); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + _app = builder.Build(); + _app.UseStaticFiles(); + _app.UseRouting(); + _app.UseAuthentication(); + _app.UseAuthorization(); + _app.UseAntiforgery(); + _app.MapRazorComponents().AddInteractiveServerRenderMode(); + + // Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav. + using (var scope = _app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + SeededGenerationId = Seed(db, SeededClusterId); + } + + await _app.StartAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + private static long Seed(OtOpcUaConfigDbContext db, string clusterId) + { + var cluster = new ServerCluster + { + ClusterId = clusterId, Name = "e2e", Enterprise = "zb", Site = "lab", + RedundancyMode = RedundancyMode.None, NodeCount = 1, CreatedBy = "e2e", + }; + var gen = new ConfigGeneration + { + ClusterId = clusterId, Status = GenerationStatus.Draft, CreatedBy = "e2e", + }; + + db.ServerClusters.Add(cluster); + db.ConfigGenerations.Add(gen); + db.SaveChanges(); + + db.UnsAreas.AddRange( + new UnsArea { UnsAreaId = "area-a", ClusterId = clusterId, Name = "warsaw", GenerationId = gen.GenerationId }, + new UnsArea { UnsAreaId = "area-b", ClusterId = clusterId, Name = "berlin", GenerationId = gen.GenerationId }); + db.UnsLines.Add(new UnsLine + { + UnsLineId = "line-a1", UnsAreaId = "area-a", Name = "oven-line", GenerationId = gen.GenerationId, + }); + db.SaveChanges(); + return gen.GenerationId; + } + + private static int GetFreeTcpPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs new file mode 100644 index 0000000..e422264 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs @@ -0,0 +1,44 @@ +using Microsoft.Playwright; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests; + +/// +/// One Playwright runtime + Chromium browser for the whole E2E suite. Tests +/// open a fresh per fixture so cookies + localStorage +/// stay isolated. Browser install is a one-time step: +/// pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium. +/// When the browser binary isn't present the suite reports a +/// so CI can distinguish missing-browser from real test failure. +/// +public sealed class PlaywrightFixture : IAsyncLifetime +{ + public IPlaywright Playwright { get; private set; } = null!; + public IBrowser Browser { get; private set; } = null!; + + public async ValueTask InitializeAsync() + { + Playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + try + { + Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); + } + catch (PlaywrightException ex) when (ex.Message.Contains("Executable doesn't exist")) + { + throw new PlaywrightBrowserMissingException(ex.Message); + } + } + + public async ValueTask DisposeAsync() + { + if (Browser is not null) await Browser.CloseAsync(); + Playwright?.Dispose(); + } +} + +/// +/// Thrown by when Chromium isn't installed. Tests +/// catching this mark themselves as "skipped" rather than "failed", so CI without +/// the install step stays green. +/// +public sealed class PlaywrightBrowserMissingException(string message) : Exception(message); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs new file mode 100644 index 0000000..7fbd197 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs @@ -0,0 +1,34 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.OtOpcUa.Admin.Services; + +namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests; + +/// +/// Stamps every request with a FleetAdmin principal so E2E tests can hit +/// authenticated Razor pages without the LDAP login flow. Registered as the +/// default authentication scheme by . +/// +public sealed class TestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "Test"; + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + new Claim(ClaimTypes.Name, "e2e-test-user"), + new Claim(ClaimTypes.Role, AdminRoles.FleetAdmin), + }; + var identity = new ClaimsIdentity(claims, SchemeName); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs new file mode 100644 index 0000000..85b98e9 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs @@ -0,0 +1,74 @@ +using Microsoft.Playwright; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests; + +/// +/// Phase 6.4 UnsTab drag-drop E2E smoke (task #199). This PR lands the Playwright + +/// WebApplicationFactory-equivalent scaffolding so future E2E coverage builds on it +/// rather than setting it up from scratch. +/// +/// +/// +/// Prerequisite. Chromium must be installed locally: +/// pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium. +/// When the binary is missing the tests rather than fail hard, +/// so CI pipelines that don't run the install step still report green. +/// +/// +/// Current scope. The host-reachability smoke below proves the infra works: +/// Kestrel-on-a-free-port, InMemory DbContext swap, +/// bypass, and Playwright-to-real-browser are all exercised. The actual drag-drop +/// interactive assertion is filed as a follow-up (task #242) because +/// Blazor Server interactive render through a test-owned pipeline needs a dedicated +/// diagnosis pass — the scaffolding lands here first so that follow-up can focus on +/// the Blazor-specific wiring instead of rebuilding the harness. +/// +/// +[Trait("Category", "E2E")] +public sealed class UnsTabDragDropE2ETests +{ + [Fact] + public async Task Admin_host_serves_HTTP_via_Playwright_scaffolding() + { + await using var app = new AdminWebAppFactory(); + await app.StartAsync(); + + PlaywrightFixture fixture; + try + { + fixture = new PlaywrightFixture(); + await fixture.InitializeAsync(); + } + catch (PlaywrightBrowserMissingException) + { + Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium."); + return; + } + + try + { + var ctx = await fixture.Browser.NewContextAsync(); + var page = await ctx.NewPageAsync(); + + // Navigate to the root. We only assert the host is live + returns HTML — not + // that the Blazor Server interactive render has booted. Booting the interactive + // circuit in a test-owned pipeline is task #242. + var response = await page.GotoAsync(app.BaseUrl); + + response.ShouldNotBeNull(); + response!.Status.ShouldBeLessThan(500, + $"Admin host returned HTTP {response.Status} at root — scaffolding broken"); + + // Static HTML shell should at least include the and some content. This + // rules out 404s + verifies the MapRazorComponents route pipeline is wired. + var body = await page.Locator("body").InnerHTMLAsync(); + body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor"); + } + finally + { + await fixture.DisposeAsync(); + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj new file mode 100644 index 0000000..242fda2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Admin.E2ETests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + +