chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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<Program></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 the Admin
|
||||
// assembly + its sibling staticwebassets manifest are discoverable. The manifest
|
||||
// maps /_framework/* to the framework NuGet cache + /app.css to the Admin source
|
||||
// wwwroot; StaticWebAssetsLoader.UseStaticWebAssets reads it and wires a composite
|
||||
// file provider automatically.
|
||||
var adminAssemblyDir = System.IO.Path.GetDirectoryName(
|
||||
typeof(Admin.Components.App).Assembly.Location)!;
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
ContentRootPath = adminAssemblyDir,
|
||||
ApplicationName = typeof(Admin.Components.App).Assembly.GetName().Name,
|
||||
});
|
||||
builder.WebHost.UseUrls(BaseUrl);
|
||||
// UseStaticWebAssets reads {ApplicationName}.staticwebassets.runtime.json (or the
|
||||
// development variant via the ASPNETCORE_HOSTINGSTARTUPASSEMBLIES convention) and
|
||||
// composes a PhysicalFileProvider per declared ContentRoot. This is what
|
||||
// `dotnet run` does automatically via the MSBuild targets — we replicate it
|
||||
// explicitly for the test-owned pipeline.
|
||||
builder.WebHost.UseStaticWebAssets();
|
||||
// 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();
|
||||
|
||||
// One InMemory database name per fixture — the lambda below runs on every DbContext
|
||||
// construction, so capturing a stable string (not calling Guid.NewGuid() inline) is
|
||||
// critical: every scope (seed, Blazor circuit, test assertions) must share the same
|
||||
// backing store or rows written in one scope disappear in the next.
|
||||
var dbName = $"e2e-{Guid.NewGuid():N}";
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseInMemoryDatabase(dbName));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user