Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
Joseph Doherty 4e96f228b2 Task #242 partial — UnsTab interactive E2E test bodies + harness upgrades (tests Skip-guarded pending blazor.web.js asset plumbing)
Carries the interactive drag-drop + 409 concurrent-edit test bodies (full Playwright
flows against the real @ondragstart/@ondragover/@ondrop handlers + modal + EF state
round-trip), plus several harness upgrades that push the in-process
WebApplication-based fixture closer to a working Blazor Server circuit. The
interactive tests are marked [Fact(Skip=...)] pending resolution of one remaining
blocker documented in the class docstring.

Harness upgrades (AdminWebAppFactory):
  - Environment set to Development so 500s surface exception stacks (rather than
    the generic error page) during future diagnosis.
  - ContentRootPath pointed at the Admin assembly dir so wwwroot + manifest files
    resolve.
  - Wired SignalR hubs (/hubs/fleet, /hubs/alerts) so ClusterDetail's HubConnection
    negotiation no longer 500s at first render.
  - Services property exposed so tests can open scoped DI contexts against the
    running host (scheduled peer-edit simulation, post-commit state assertion).

Remaining blocker (reason for Skip):
  /_framework/blazor.web.js returns HTTP 200 with a zero-byte body. The asset's
  route is declared in OtOpcUa.Admin.staticwebassets.endpoints.json, but the
  underlying file is shipped by the framework NuGet package
  (Microsoft.AspNetCore.App.Internal.Assets/_framework/blazor.web.js) rather than
  copied into the Admin wwwroot. MapStaticAssets can't resolve it without wiring
  a composite FileProvider or the WebRootPath machinery. Three viable next-session
  approaches listed in the class docstring:
    (a) Composite FileProvider mapping /_framework/* → NuGet cache.
    (b) Subprocess harness spawning real dotnet run of Admin project with an
        InMemory-DB override (closest to production composition).
    (c) MSBuild ItemGroup in the test csproj that copies framework files into the
        test output + ContentRoot=test assembly dir with UseStaticFiles.

Scaffolding smoke test (Admin_host_serves_HTTP_via_Playwright_scaffolding) stays
green unchanged.

Suite state: 1 passed, 2 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 02:09:44 -04:00

161 lines
7.2 KiB
C#

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.Admin.Hubs;
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;
/// <summary>
/// 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
/// <see cref="TestAuthHandler"/>. Playwright connects to <see cref="BaseUrl"/>.
/// InMemory is sufficient because UnsService's drag-drop path exercises EF operations,
/// not raw SQL.
/// </summary>
/// <remarks>
/// We deliberately build a <see cref="WebApplication"/> directly rather than going through
/// <c>WebApplicationFactory&lt;Program&gt;</c> — 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.
/// </remarks>
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";
/// <summary>
/// Root service provider of the running host. Tests use this to create scopes that
/// share the InMemory DB with the Blazor-rendered page — e.g. to assert post-commit
/// state, or to simulate a concurrent peer edit that bumps the DraftRevisionToken
/// between preview-open and Confirm-click.
/// </summary>
public IServiceProvider Services => _app?.Services
?? throw new InvalidOperationException("AdminWebAppFactory: StartAsync has not been called");
public async Task StartAsync()
{
var port = GetFreeTcpPort();
BaseUrl = $"http://127.0.0.1:{port}";
// Point the content root at the Admin project's build output so wwwroot/ (app.css,
// site CSS, icons) + the Blazor framework assets served from the Admin assembly
// resolve. Without this the default content root is the test project's bin dir and
// blazor.web.js + app.css return 404, which keeps the interactive circuit from
// booting at all.
var adminAssemblyDir = System.IO.Path.GetDirectoryName(
typeof(Admin.Components.App).Assembly.Location)!;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
ContentRootPath = adminAssemblyDir,
});
builder.WebHost.UseUrls(BaseUrl);
// E2E host runs in Development so unhandled exceptions during Blazor render surface
// as visible 500s with stacks the test can capture — prod-style generic errors make
// diagnosis of circuit / DI misconfig effectively impossible.
builder.Environment.EnvironmentName = Microsoft.Extensions.Hosting.Environments.Development;
// --- 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<AuthenticationSchemeOptions, TestAuthHandler>(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<OtOpcUaConfigDbContext>(opt =>
opt.UseInMemoryDatabase($"e2e-{Guid.NewGuid():N}"));
builder.Services.AddScoped<Admin.Services.ClusterService>();
builder.Services.AddScoped<Admin.Services.GenerationService>();
builder.Services.AddScoped<Admin.Services.UnsService>();
builder.Services.AddScoped<Admin.Services.EquipmentService>();
builder.Services.AddScoped<Admin.Services.NamespaceService>();
builder.Services.AddScoped<Admin.Services.DriverInstanceService>();
builder.Services.AddScoped<Admin.Services.DraftValidationService>();
_app = builder.Build();
_app.UseStaticFiles();
_app.UseRouting();
_app.UseAuthentication();
_app.UseAuthorization();
_app.UseAntiforgery();
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
// The ClusterDetail + other pages connect SignalR hubs at render time — the
// endpoints must exist or the Blazor circuit surfaces a 500 on first interactive
// step. No background pollers (FleetStatusPoller etc.) are registered so the hubs
// stay quiet until something pushes through IHubContext, which the E2E tests don't.
_app.MapHub<FleetStatusHub>("/hubs/fleet");
_app.MapHub<AlertHub>("/hubs/alerts");
// 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<OtOpcUaConfigDbContext>();
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;
}
}