diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 2c304cb..25ec612 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -36,6 +36,7 @@
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
new file mode 100644
index 0000000..cccb703
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/AdminWebAppFactory.cs
@@ -0,0 +1,130 @@
+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.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
+
+///
+/// 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
+/// . Playwright connects to .
+/// InMemory is sufficient because UnsService's drag-drop path exercises EF operations,
+/// not raw SQL.
+///
+///
+/// We deliberately build a directly rather than going through
+/// WebApplicationFactory<Program> — 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.
+///
+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";
+
+ public async Task StartAsync()
+ {
+ var port = GetFreeTcpPort();
+ BaseUrl = $"http://127.0.0.1:{port}";
+
+ var builder = WebApplication.CreateBuilder(Array.Empty());
+ builder.WebHost.UseUrls(BaseUrl);
+
+ // --- 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(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(opt =>
+ opt.UseInMemoryDatabase($"e2e-{Guid.NewGuid():N}"));
+
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+
+ _app = builder.Build();
+ _app.UseStaticFiles();
+ _app.UseRouting();
+ _app.UseAuthentication();
+ _app.UseAuthorization();
+ _app.UseAntiforgery();
+ _app.MapRazorComponents().AddInteractiveServerRenderMode();
+
+ // 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();
+ 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;
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs
new file mode 100644
index 0000000..e422264
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/PlaywrightFixture.cs
@@ -0,0 +1,44 @@
+using Microsoft.Playwright;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
+
+///
+/// One Playwright runtime + Chromium browser for the whole E2E suite. Tests
+/// open a fresh per fixture so cookies + localStorage
+/// stay isolated. Browser install is a one-time step:
+/// pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium.
+/// When the browser binary isn't present the suite reports a
+/// so CI can distinguish missing-browser from real test failure.
+///
+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();
+ }
+}
+
+///
+/// Thrown by when Chromium isn't installed. Tests
+/// catching this mark themselves as "skipped" rather than "failed", so CI without
+/// the install step stays green.
+///
+public sealed class PlaywrightBrowserMissingException(string message) : Exception(message);
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs
new file mode 100644
index 0000000..7fbd197
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/TestAuthHandler.cs
@@ -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;
+
+///
+/// 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 .
+///
+public sealed class TestAuthHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder)
+ : AuthenticationHandler(options, logger, encoder)
+{
+ public const string SchemeName = "Test";
+
+ protected override Task 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));
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs
new file mode 100644
index 0000000..85b98e9
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs
@@ -0,0 +1,74 @@
+using Microsoft.Playwright;
+using Shouldly;
+using Xunit;
+
+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.
+///
+///
+///
+/// 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.
+///
+///
+[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();
+
+ PlaywrightFixture fixture;
+ try
+ {
+ fixture = new PlaywrightFixture();
+ await fixture.InitializeAsync();
+ }
+ catch (PlaywrightBrowserMissingException)
+ {
+ Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
+ 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");
+ }
+ finally
+ {
+ await fixture.DisposeAsync();
+ }
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj
new file mode 100644
index 0000000..242fda2
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Admin.E2ETests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+