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 + + + + + + + + + + + + + + + +