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:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -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&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 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;
}
}

View File

@@ -0,0 +1,44 @@
using Microsoft.Playwright;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
/// <summary>
/// One Playwright runtime + Chromium browser for the whole E2E suite. Tests
/// open a fresh <see cref="IBrowserContext"/> per fixture so cookies + localStorage
/// stay isolated. Browser install is a one-time step:
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
/// When the browser binary isn't present the suite reports a <see cref="PlaywrightBrowserMissingException"/>
/// so CI can distinguish missing-browser from real test failure.
/// </summary>
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();
}
}
/// <summary>
/// Thrown by <see cref="PlaywrightFixture"/> when Chromium isn't installed. Tests
/// catching this mark themselves as "skipped" rather than "failed", so CI without
/// the install step stays green.
/// </summary>
public sealed class PlaywrightBrowserMissingException(string message) : Exception(message);

View File

@@ -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;
/// <summary>
/// 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 <see cref="AdminWebAppFactory"/>.
/// </summary>
public sealed class TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "Test";
protected override Task<AuthenticateResult> 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));
}
}

View File

@@ -0,0 +1,209 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Playwright;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
/// <summary>
/// Phase 6.4 UnsTab drag-drop E2E. Task #199 landed the scaffolding; task #242 (this file)
/// drives the Blazor Server interactive circuit through a real drag-drop → confirm-modal
/// → apply flow and a 409 concurrent-edit flow, both via Chromium.
/// </summary>
/// <remarks>
/// <para>
/// <b>Prerequisite.</b> Chromium must be installed locally:
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
/// When the binary is missing the tests <see cref="Assert.Skip"/> rather than fail hard,
/// so CI pipelines that don't run the install step still report green.
/// </para>
/// <para>
/// <b>Harness notes.</b> <see cref="AdminWebAppFactory"/> points the content root at
/// the Admin assembly directory + sets <c>ApplicationName</c> + calls
/// <c>UseStaticWebAssets</c> so <c>/_framework/blazor.web.js</c> + <c>/app.css</c>
/// resolve from the Admin's <c>staticwebassets.development.json</c> manifest (which
/// stitches together Admin <c>wwwroot</c> + the framework NuGet cache). Hubs
/// <c>/hubs/fleet</c> + <c>/hubs/alerts</c> are mapped so <c>ClusterDetail</c>'s
/// <c>HubConnection</c> negotiation doesn't 500 at first render. The InMemory
/// database name is captured as a stable string per fixture instance so the seed
/// scope + Blazor circuit scope + test-assertion scope all share one backing store.
/// </para>
/// </remarks>
[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();
var fixture = await TryInitPlaywrightAsync();
if (fixture is null) return;
try
{
var ctx = await fixture.Browser.NewContextAsync();
var page = await ctx.NewPageAsync();
var response = await page.GotoAsync(app.BaseUrl);
response.ShouldNotBeNull();
response!.Status.ShouldBeLessThan(500,
$"Admin host returned HTTP {response.Status} at root — scaffolding broken");
var body = await page.Locator("body").InnerHTMLAsync();
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
}
finally
{
await fixture.DisposeAsync();
}
}
[Fact]
public async Task Dragging_line_onto_new_area_shows_preview_modal_then_confirms_the_move()
{
await using var app = new AdminWebAppFactory();
await app.StartAsync();
var fixture = await TryInitPlaywrightAsync();
if (fixture is null) return;
try
{
var ctx = await fixture.Browser.NewContextAsync();
var page = await ctx.NewPageAsync();
await OpenUnsTabAsync(page, app);
// The seed wires line 'oven-line' to area 'warsaw' (area-a); dragging it onto
// 'berlin' (area-b) should surface the preview modal. Playwright's DragToAsync
// dispatches native dragstart / dragover / drop events that the razor's
// @ondragstart / @ondragover / @ondrop handlers pick up.
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
await lineRow.DragToAsync(berlinRow);
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
var modalBody = await page.Locator(".modal-body").InnerTextAsync();
modalBody.ShouldContain("Equipment re-homed",
customMessage: "preview modal should render UnsImpactAnalyzer summary");
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
.ClickAsync();
// Modal dismisses after the MoveLineAsync round-trip + ReloadAsync.
await modalTitle.WaitForAsync(new() { State = WaitForSelectorState.Detached, Timeout = 10_000 });
// Persisted state: the line row now shows area-b as its Area column value.
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var line = await db.UnsLines.AsNoTracking()
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
line.UnsAreaId.ShouldBe("area-b",
"drag-drop should have moved the line to the berlin area via UnsService.MoveLineAsync");
}
finally
{
await fixture.DisposeAsync();
}
}
[Fact]
public async Task Preview_shown_then_peer_edit_applied_surfaces_409_conflict_modal()
{
await using var app = new AdminWebAppFactory();
await app.StartAsync();
var fixture = await TryInitPlaywrightAsync();
if (fixture is null) return;
try
{
var ctx = await fixture.Browser.NewContextAsync();
var page = await ctx.NewPageAsync();
await OpenUnsTabAsync(page, app);
// Open the preview first (same drag as the happy-path test). The preview captures
// a RevisionToken under the current draft state.
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
await lineRow.DragToAsync(berlinRow);
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
// Simulate a concurrent operator committing their own edit between the preview
// open + our Confirm click — bumps the DraftRevisionToken so our stale token hits
// DraftRevisionConflictException in UnsService.MoveLineAsync.
using (var scope = app.Services.CreateScope())
{
var uns = scope.ServiceProvider.GetRequiredService<Admin.Services.UnsService>();
await uns.AddAreaAsync(app.SeededGenerationId, app.SeededClusterId,
"madrid", notes: null, CancellationToken.None);
}
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
.ClickAsync();
var conflictTitle = page.Locator(".modal-title",
new() { HasTextString = "Draft changed" });
await conflictTitle.WaitForAsync(new() { Timeout = 10_000 });
// Persisted state: line still points at the original area-a — the conflict short-
// circuited the move.
using var verifyScope = app.Services.CreateScope();
var db = verifyScope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
var line = await db.UnsLines.AsNoTracking()
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
line.UnsAreaId.ShouldBe("area-a",
"conflict path must not overwrite the peer operator's draft state");
}
finally
{
await fixture.DisposeAsync();
}
}
private static async Task<PlaywrightFixture?> TryInitPlaywrightAsync()
{
try
{
var fixture = new PlaywrightFixture();
await fixture.InitializeAsync();
return fixture;
}
catch (PlaywrightBrowserMissingException)
{
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
return null;
}
}
/// <summary>
/// Navigates to the seeded cluster and switches to the UNS Structure tab, waiting for
/// the Blazor Server interactive circuit to render the draggable line table. Returns
/// once the drop-target cells ("drop here") are visible — that's the signal the
/// circuit is live and @ondragstart handlers are wired.
/// </summary>
private static async Task OpenUnsTabAsync(IPage page, AdminWebAppFactory app)
{
await page.GotoAsync($"{app.BaseUrl}/clusters/{app.SeededClusterId}",
new() { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 20_000 });
var unsTab = page.Locator("button.nav-link", new() { HasTextString = "UNS Structure" });
await unsTab.WaitForAsync(new() { Timeout = 15_000 });
await unsTab.ClickAsync();
// "drop here" is the per-area hint cell — only rendered inside <UnsTab> so its
// visibility confirms both the tab switch and the circuit's interactive render.
await page.Locator("td", new() { HasTextString = "drop here" })
.First.WaitForAsync(new() { Timeout = 15_000 });
}
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin.E2ETests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
<PackageReference Include="Microsoft.Playwright" Version="1.51.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Admin\ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>