Closes the scope-out left by the #242 partial. Root cause of the blazor.web.js zero-byte response turned out to be two co-operating harness bugs: 1) The static-asset manifest was discoverable but the runtime needs UseStaticWebAssets to be called so the StaticWebAssetsLoader composes a PhysicalFileProvider per ContentRoot declared in staticwebassets.development.json (Admin source wwwroot + obj/compressed + the framework NuGet cache). Without that call MapStaticAssets resolves the route but has no ContentRoot map — so every asset serves zero bytes. 2) The EF InMemory DB name was being re-generated on every DbContext construction (the lambda body called Guid.NewGuid() inline), so the seed scope, Blazor circuit scope, and test-assertion scopes all got separate stores. Capturing the name as a stable string per fixture instance fixes the "cluster not found → page stays at Loading…" symptom. Fixes: - AdminWebAppFactory: * ApplicationName set on WebApplicationOptions so UseStaticWebAssets discovers the manifest. * builder.WebHost.UseStaticWebAssets() wired explicitly (matches what `dotnet run` does via MSBuild targets). * dbName captured once per fixture; the options lambda reads the captured string instead of re-rolling a Guid. - UnsTabDragDropE2ETests: the two [Fact(Skip=...)] tests un-skip. Suite state: 3 passed, 0 skipped, 0 failed. Task #242 closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
9.2 KiB
C#
210 lines
9.2 KiB
C#
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 });
|
|
}
|
|
}
|