diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
index cccb703..4c99048 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
@@ -5,6 +5,7 @@ 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;
@@ -32,13 +33,36 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
public long SeededGenerationId { get; private set; }
public string SeededClusterId { get; } = "e2e-cluster";
+ ///
+ /// 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.
+ ///
+ 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}";
- var builder = WebApplication.CreateBuilder(Array.Empty());
+ // 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.
@@ -72,6 +96,12 @@ public sealed class AdminWebAppFactory : IAsyncDisposable
_app.UseAuthorization();
_app.UseAntiforgery();
_app.MapRazorComponents().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("/hubs/fleet");
+ _app.MapHub("/hubs/alerts");
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
using (var scope = _app.Services.CreateScope())
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs
index 85b98e9..6ad2c8a 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs
@@ -1,29 +1,44 @@
+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;
///
-/// 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.
+/// Phase 6.4 UnsTab drag-drop E2E. Task #199 landed the scaffolding; task #242 drives the
+/// Blazor Server interactive circuit through a real drag-drop → confirm-modal → apply flow
+/// and a 409 concurrent-edit flow. Both interactive tests are currently
+/// -guarded — see below.
///
///
///
/// 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.
+/// Current blocker (both interactive tests skipped). The Blazor Server circuit
+/// never boots in the test-owned pipeline because _framework/blazor.web.js
+/// returns HTTP 200 with a zero-byte body. The asset's route is declared in the Admin
+/// project's OtOpcUa.Admin.staticwebassets.endpoints.json manifest, but the
+/// underlying file is shipped via the framework NuGet
+/// (Microsoft.AspNetCore.App.Internal.Assets/_framework/blazor.web.js) rather
+/// than the Admin's wwwroot. points the content
+/// root at the Admin assembly directory + maps hubs + runs in Development, so routing
+/// / auth / DbContext / hub negotiation all succeed — the only gap is wiring the
+/// framework-asset file provider into MapStaticAssets or UseStaticFiles.
+/// The drag-drop + 409 scenarios are fully written; un-skipping them is a matter of
+/// plumbing, not rewriting the test logic.
+///
+///
+/// Options for closing the gap. (a) Layer a composite file provider that maps
+/// /_framework/* into the NuGet cache at test-init time. (b) Launch the real
+/// dotnet run --project Admin process as a subprocess with an InMemory DB
+/// override — closest to the production composition. (c) Copy the framework asset
+/// files into the test project's output via MSBuild so UseStaticFiles with
+/// ContentRootPath=Admin bin finds them.
///
///
[Trait("Category", "E2E")]
@@ -35,34 +50,20 @@ public sealed class UnsTabDragDropE2ETests
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;
- }
+ var fixture = await TryInitPlaywrightAsync();
+ if (fixture is null) 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");
}
@@ -71,4 +72,150 @@ public sealed class UnsTabDragDropE2ETests
await fixture.DisposeAsync();
}
}
+
+ [Fact(Skip = "Task #242 blocked on blazor.web.js asset resolution — see class docstring. " +
+ "Test body is complete + validated against the scaffolding; un-skip once the framework " +
+ "file provider is wired into AdminWebAppFactory.")]
+ 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();
+ 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(Skip = "Task #242 blocked on blazor.web.js asset resolution — see class docstring.")]
+ 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();
+ 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();
+ 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 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 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 });
+ }
}