diff --git a/.github/workflows/LANCommander.PR.yml b/.github/workflows/LANCommander.PR.yml
index dcdeb1fe..72c8157d 100644
--- a/.github/workflows/LANCommander.PR.yml
+++ b/.github/workflows/LANCommander.PR.yml
@@ -12,6 +12,8 @@ on:
permissions:
contents: write
packages: read
+ checks: write
+ pull-requests: write
jobs:
prep:
@@ -56,6 +58,64 @@ jobs:
echo "version_semver=$VERSION_SEMVER" >> $GITHUB_OUTPUT
echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
+ ui_tests:
+ needs: [prep]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ needs.prep.outputs.build_dotnet_version }}
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install Node packages
+ run: |
+ npm install --prefix ./LANCommander.UI
+ npm install --prefix ./LANCommander.Server
+
+ # The Monaco editor's PowerShell completions are generated (gitignored) and
+ # required by the frontend webpack build. The in-build MSBuild target uses
+ # Windows-style paths, so generate explicitly here for the Linux runner.
+ - name: Generate PowerShell Completions
+ run: dotnet run --project ./LANCommander.CompletionGenerator/LANCommander.CompletionGenerator.csproj -- ./LANCommander.UI/Components/MonacoCodeEditor/PowerShellCompletions.g.ts
+
+ - name: Restore dependencies
+ run: dotnet restore LANCommander.Server.UI.Tests
+
+ - name: Build test project
+ run: dotnet build LANCommander.Server.UI.Tests --no-restore --configuration Release
+
+ - name: Install Playwright browsers
+ run: pwsh LANCommander.Server.UI.Tests/bin/Release/net10.0/playwright.ps1 install --with-deps chromium
+
+ - name: Run UI tests
+ run: dotnet test LANCommander.Server.UI.Tests --no-build --configuration Release --logger "trx;LogFileName=ui-test-results.trx" --results-directory ./TestResults
+ env:
+ SCREENSHOT_DIR: ${{ github.workspace }}/TestResults/Screenshots
+
+ - name: Test report
+ if: always()
+ uses: dorny/test-reporter@v1
+ with:
+ name: UI Test Results
+ path: ./TestResults/ui-test-results.trx
+ reporter: dotnet-trx
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ui-test-results
+ path: ./TestResults
+ retention-days: 7
+
build_server_linux_arm64:
needs: [prep]
uses: ./.github/workflows/LANCommander.Server.yml
diff --git a/Directory.Packages.props b/Directory.Packages.props
index bb129ec5..f2f874c5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -69,7 +69,9 @@
+
+
diff --git a/LANCommander.Launcher.Tests/Baselines/GameActionBar_Interaction_BecomesRunning.png b/LANCommander.Launcher.Tests/Baselines/GameActionBar_Interaction_BecomesRunning.png
index 3cd7c180..eb9c8dc4 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/GameActionBar_Interaction_BecomesRunning.png and b/LANCommander.Launcher.Tests/Baselines/GameActionBar_Interaction_BecomesRunning.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/GameActionBar_Running.png b/LANCommander.Launcher.Tests/Baselines/GameActionBar_Running.png
index 3cd7c180..eb9c8dc4 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/GameActionBar_Running.png and b/LANCommander.Launcher.Tests/Baselines/GameActionBar_Running.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/GameDetailView_Installed.png b/LANCommander.Launcher.Tests/Baselines/GameDetailView_Installed.png
index 709847ae..0e2c5f4f 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/GameDetailView_Installed.png and b/LANCommander.Launcher.Tests/Baselines/GameDetailView_Installed.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/GameDetailView_NotInstalled.png b/LANCommander.Launcher.Tests/Baselines/GameDetailView_NotInstalled.png
index 2ea79004..422b8890 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/GameDetailView_NotInstalled.png and b/LANCommander.Launcher.Tests/Baselines/GameDetailView_NotInstalled.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/GamesListView.png b/LANCommander.Launcher.Tests/Baselines/GamesListView.png
index fc7f6d10..a60df664 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/GamesListView.png and b/LANCommander.Launcher.Tests/Baselines/GamesListView.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/GamesListView_Empty.png b/LANCommander.Launcher.Tests/Baselines/GamesListView_Empty.png
index 5d81a5d7..e413e607 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/GamesListView_Empty.png and b/LANCommander.Launcher.Tests/Baselines/GamesListView_Empty.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_EmptyAfterClear.png b/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_EmptyAfterClear.png
index 5d81a5d7..e413e607 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_EmptyAfterClear.png and b/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_EmptyAfterClear.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_FilteredToOne.png b/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_FilteredToOne.png
index eb0cff34..58a76f0e 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_FilteredToOne.png and b/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_FilteredToOne.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/LoginView.png b/LANCommander.Launcher.Tests/Baselines/LoginView.png
index 2390009d..8cf561a8 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/LoginView.png and b/LANCommander.Launcher.Tests/Baselines/LoginView.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/ServerSelectionView.png b/LANCommander.Launcher.Tests/Baselines/ServerSelectionView.png
index cda01065..a63145a5 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/ServerSelectionView.png and b/LANCommander.Launcher.Tests/Baselines/ServerSelectionView.png differ
diff --git a/LANCommander.Launcher.Tests/Baselines/SplashView.png b/LANCommander.Launcher.Tests/Baselines/SplashView.png
index 174296e1..a2a12714 100644
Binary files a/LANCommander.Launcher.Tests/Baselines/SplashView.png and b/LANCommander.Launcher.Tests/Baselines/SplashView.png differ
diff --git a/LANCommander.Launcher.Tests/TestApp.axaml b/LANCommander.Launcher.Tests/TestApp.axaml
index 8cd4a9d6..7ba5b01c 100644
--- a/LANCommander.Launcher.Tests/TestApp.axaml
+++ b/LANCommander.Launcher.Tests/TestApp.axaml
@@ -1,8 +1,16 @@
+
+
+
+
+
diff --git a/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs b/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs
index 1270daa6..9e6979c4 100644
--- a/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs
+++ b/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs
@@ -3,6 +3,7 @@ using System.Collections.ObjectModel;
using System.IO;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
+using LANCommander.Launcher.Services;
using LANCommander.Launcher.Tests.Helpers;
using LANCommander.Launcher.ViewModels;
using LANCommander.Launcher.ViewModels.Components;
@@ -31,12 +32,23 @@ public class ViewLayoutTests
private const int WindowWidth = 1200;
private const int WindowHeight = 800;
+ static ViewLayoutTests()
+ {
+ // The Login, Splash and ServerSelection views pick a random full-screen
+ // background on load. Disable that here so the captured screenshots — and the
+ // committed baselines — are deterministic; otherwise every run compares against
+ // a different photo and reports a spurious regression.
+ ViewBackground.Enabled = false;
+ }
+
// ---------------------------------------------------------------------------
// Service provider shared by all tests that need ViewModels with DI dependencies.
- // Minimal: just logging — no real SDK services needed for layout-only rendering.
+ // Minimal: logging plus navigation — GameDetailViewModel resolves INavigationService
+ // in its constructor. No real SDK services needed for layout-only rendering.
// ---------------------------------------------------------------------------
private static readonly IServiceProvider _testServices = new ServiceCollection()
.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Warning))
+ .AddSingleton()
.BuildServiceProvider();
// ---------------------------------------------------------------------------
diff --git a/LANCommander.Launcher/LANCommander.Launcher.csproj b/LANCommander.Launcher/LANCommander.Launcher.csproj
index 2a44afa9..9a52d8ab 100644
--- a/LANCommander.Launcher/LANCommander.Launcher.csproj
+++ b/LANCommander.Launcher/LANCommander.Launcher.csproj
@@ -36,6 +36,10 @@
+
+
+
+
diff --git a/LANCommander.Launcher/Views/LoginView.axaml.cs b/LANCommander.Launcher/Views/LoginView.axaml.cs
index 0ce24243..b620c0a4 100644
--- a/LANCommander.Launcher/Views/LoginView.axaml.cs
+++ b/LANCommander.Launcher/Views/LoginView.axaml.cs
@@ -1,23 +1,10 @@
-using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
-using Avalonia.Media.Imaging;
-using Avalonia.Platform;
namespace LANCommander.Launcher.Views;
public partial class LoginView : UserControl
{
- private static readonly string[] Backgrounds =
- {
- "avares://LANCommander.Launcher/Assets/backgrounds/aoe2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/ns2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/css.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/bfme2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/soldat2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/ut2004.jpg",
- };
-
public LoginView()
{
InitializeComponent();
@@ -26,12 +13,7 @@ public partial class LoginView : UserControl
private void OnLoaded(object? sender, RoutedEventArgs e)
{
- try
- {
- var uri = new Uri(Backgrounds[Random.Shared.Next(Backgrounds.Length)]);
- BackgroundImage.Source = new Bitmap(AssetLoader.Open(uri));
- }
- catch { /* silently ignore missing assets */ }
+ ViewBackground.Apply(BackgroundImage);
UsernameTextBox.Focus();
}
diff --git a/LANCommander.Launcher/Views/ServerSelectionView.axaml.cs b/LANCommander.Launcher/Views/ServerSelectionView.axaml.cs
index 02d89ceb..5d34053d 100644
--- a/LANCommander.Launcher/Views/ServerSelectionView.axaml.cs
+++ b/LANCommander.Launcher/Views/ServerSelectionView.axaml.cs
@@ -1,23 +1,10 @@
-using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
-using Avalonia.Media.Imaging;
-using Avalonia.Platform;
namespace LANCommander.Launcher.Views;
public partial class ServerSelectionView : UserControl
{
- private static readonly string[] Backgrounds =
- {
- "avares://LANCommander.Launcher/Assets/backgrounds/aoe2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/ns2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/css.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/bfme2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/soldat2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/ut2004.jpg",
- };
-
public ServerSelectionView()
{
InitializeComponent();
@@ -26,13 +13,8 @@ public partial class ServerSelectionView : UserControl
private void OnLoaded(object? sender, RoutedEventArgs e)
{
- try
- {
- var uri = new Uri(Backgrounds[Random.Shared.Next(Backgrounds.Length)]);
- BackgroundImage.Source = new Bitmap(AssetLoader.Open(uri));
- }
- catch { /* silently ignore missing assets */ }
-
+ ViewBackground.Apply(BackgroundImage);
+
ServerAddressTextBox.Focus();
}
}
diff --git a/LANCommander.Launcher/Views/SplashView.axaml.cs b/LANCommander.Launcher/Views/SplashView.axaml.cs
index 1654c2d7..fc3e4f78 100644
--- a/LANCommander.Launcher/Views/SplashView.axaml.cs
+++ b/LANCommander.Launcher/Views/SplashView.axaml.cs
@@ -1,23 +1,10 @@
-using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
-using Avalonia.Media.Imaging;
-using Avalonia.Platform;
namespace LANCommander.Launcher.Views;
public partial class SplashView : UserControl
{
- private static readonly string[] Backgrounds =
- {
- "avares://LANCommander.Launcher/Assets/backgrounds/aoe2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/ns2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/css.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/bfme2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/soldat2.jpg",
- "avares://LANCommander.Launcher/Assets/backgrounds/ut2004.jpg",
- };
-
public SplashView()
{
InitializeComponent();
@@ -26,11 +13,6 @@ public partial class SplashView : UserControl
private void OnLoaded(object? sender, RoutedEventArgs e)
{
- try
- {
- var uri = new Uri(Backgrounds[Random.Shared.Next(Backgrounds.Length)]);
- BackgroundImage.Source = new Bitmap(AssetLoader.Open(uri));
- }
- catch { /* silently ignore missing assets */ }
+ ViewBackground.Apply(BackgroundImage);
}
}
diff --git a/LANCommander.Launcher/Views/ViewBackground.cs b/LANCommander.Launcher/Views/ViewBackground.cs
new file mode 100644
index 00000000..3fb958cd
--- /dev/null
+++ b/LANCommander.Launcher/Views/ViewBackground.cs
@@ -0,0 +1,43 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+
+namespace LANCommander.Launcher.Views;
+
+///
+/// Picks a random full-screen background for the Login, Splash and ServerSelection
+/// views. Visual-regression tests disable the random pick via
+/// so the rendered output (and therefore the committed baseline) stays deterministic.
+///
+internal static class ViewBackground
+{
+ private static readonly string[] Backgrounds =
+ {
+ "avares://LANCommander.Launcher/Assets/backgrounds/aoe2.jpg",
+ "avares://LANCommander.Launcher/Assets/backgrounds/ns2.jpg",
+ "avares://LANCommander.Launcher/Assets/backgrounds/css.jpg",
+ "avares://LANCommander.Launcher/Assets/backgrounds/bfme2.jpg",
+ "avares://LANCommander.Launcher/Assets/backgrounds/soldat2.jpg",
+ "avares://LANCommander.Launcher/Assets/backgrounds/ut2004.jpg",
+ };
+
+ ///
+ /// When false, no random background is loaded. Set by visual-regression tests so
+ /// the rendered output is deterministic across runs.
+ ///
+ public static bool Enabled { get; set; } = true;
+
+ public static void Apply(Image target)
+ {
+ if (!Enabled)
+ return;
+
+ try
+ {
+ var uri = new Uri(Backgrounds[Random.Shared.Next(Backgrounds.Length)]);
+ target.Source = new Bitmap(AssetLoader.Open(uri));
+ }
+ catch { /* silently ignore missing assets */ }
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/AssemblyInfo.cs b/LANCommander.Server.UI.Tests/AssemblyInfo.cs
new file mode 100644
index 00000000..399d4dee
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using Xunit;
+
+// Disable parallel test execution - these tests share a server port and data directory
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
diff --git a/LANCommander.Server.UI.Tests/Components/BUnitServerFixture.cs b/LANCommander.Server.UI.Tests/Components/BUnitServerFixture.cs
new file mode 100644
index 00000000..d87e0464
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Components/BUnitServerFixture.cs
@@ -0,0 +1,104 @@
+using LANCommander.SDK.Enums;
+using LANCommander.Server.Data;
+using LANCommander.Server.Data.Models;
+using LANCommander.Server.Services;
+using LANCommander.Server.Settings.Enums;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LANCommander.Server.UI.Tests.Components;
+
+///
+/// Shared fixture for bUnit component tests. Reuses the proven
+/// to stand up the real server dependency-injection container backed by a file-based SQLite
+/// database, seeds an admin user and a single test game, then exposes the real service provider so
+/// bUnit can resolve the server's scoped services (GameService, AntDesign, etc.) while rendering
+/// components in-process.
+///
+/// Unlike this does NOT start Playwright — bUnit renders
+/// components synchronously in-process and needs only the DI container and seeded data.
+///
+public class BUnitServerFixture : IAsyncLifetime
+{
+ public UITestApplicationFactory Factory { get; private set; } = null!;
+
+ ///
+ /// ID of a game created via the service layer for edit component tests.
+ ///
+ public Guid TestGameId { get; private set; }
+ public const string TestGameTitle = "Test Game";
+
+ public async Task InitializeAsync()
+ {
+ Factory = new UITestApplicationFactory();
+ // Trigger the factory to build the host and create the SQLite schema.
+ _ = Factory.Services;
+
+ using var scope = Factory.RealServices.CreateScope();
+ var roleService = scope.ServiceProvider.GetRequiredService();
+ var userService = scope.ServiceProvider.GetRequiredService();
+
+ await roleService.AddAsync(new Role { Name = RoleService.AdministratorRoleName });
+ var user = await userService.AddAsync(new User { UserName = TestConstants.AdminUserName });
+ await userService.ChangePassword(user.UserName, TestConstants.AdminPassword);
+ await userService.AddToRoleAsync(user.UserName, RoleService.AdministratorRoleName);
+
+ // Seed default storage locations so service initialization mirrors a real server.
+ var storageLocationService = scope.ServiceProvider.GetRequiredService();
+
+ var archivePath = Path.Combine(Path.GetTempPath(), "LANCommander_BUnit_Archives");
+ Directory.CreateDirectory(archivePath);
+ await storageLocationService.AddAsync(new StorageLocation
+ {
+ Path = archivePath,
+ Type = StorageLocationType.Archive,
+ Default = true
+ });
+
+ var savePath = Path.Combine(Path.GetTempPath(), "LANCommander_BUnit_Saves");
+ Directory.CreateDirectory(savePath);
+ await storageLocationService.AddAsync(new StorageLocation
+ {
+ Path = savePath,
+ Type = StorageLocationType.Save,
+ Default = true
+ });
+
+ var mediaPath = Path.Combine(Path.GetTempPath(), "LANCommander_BUnit_Media");
+ Directory.CreateDirectory(mediaPath);
+ await storageLocationService.AddAsync(new StorageLocation
+ {
+ Path = mediaPath,
+ Type = StorageLocationType.Media,
+ Default = true
+ });
+
+ // Seed a test game via the service layer for edit component tests.
+ var gameService = scope.ServiceProvider.GetRequiredService();
+ var game = await gameService.AddAsync(new Game
+ {
+ Title = TestGameTitle,
+ Type = GameType.MainGame,
+ Singleplayer = true
+ });
+ TestGameId = game.Id;
+
+ // Mark the provider as configured so the app behaves as a set-up server.
+ DatabaseContext.Provider = DatabaseProvider.SQLite;
+ }
+
+ public async Task DisposeAsync()
+ {
+ DatabaseContext.Provider = DatabaseProvider.Unknown;
+ await Factory.DisposeAsync();
+ }
+}
+
+///
+/// xUnit collection definition that shares a single across all
+/// bUnit component test classes, keeping them isolated from the Playwright "Server" collection so
+/// the static is not contended.
+///
+[CollectionDefinition("BUnit")]
+public class BUnitCollection : ICollectionFixture
+{
+}
diff --git a/LANCommander.Server.UI.Tests/Components/BUnitTestContext.cs b/LANCommander.Server.UI.Tests/Components/BUnitTestContext.cs
new file mode 100644
index 00000000..2a8e33ff
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Components/BUnitTestContext.cs
@@ -0,0 +1,81 @@
+using Bunit;
+using Bunit.TestDoubles;
+using LANCommander.Server.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LANCommander.Server.UI.Tests.Components;
+
+///
+/// Base class for bUnit component tests. Renders Blazor components in-process and synchronously,
+/// eliminating the SignalR circuit round-trips that make the Playwright suite flaky.
+///
+/// Server services (GameService, AntDesign, EF, etc.) are resolved from the real DI container
+/// created by via a fallback service provider. A fresh scope is
+/// created per test so scoped services (and their DbContexts) behave like a single request.
+///
+public abstract class BUnitTestContext : Bunit.TestContext
+{
+ private readonly IServiceScope _scope;
+
+ protected BUnitServerFixture Fixture { get; }
+
+ protected BUnitTestContext(BUnitServerFixture fixture)
+ {
+ Fixture = fixture;
+
+ // A per-test scope so scoped services (GameService, DbContext) resolve correctly when the
+ // fallback provider is hit during rendering.
+ _scope = fixture.Factory.RealServices.CreateScope();
+
+ // AntDesign components issue many JS interop calls for DOM measurement; loose mode returns
+ // defaults so rendering can proceed without a browser.
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ // Select.SetDropdownStyleAsync (OnAfterRenderAsync) dereferences the bounding-rect result;
+ // loose mode would hand back a null DomRect and throw. Return a real (zero-sized) rect so
+ // AntDesign Select/DatePicker components render without a browser.
+ JSInterop
+ .Setup(
+ "AntDesign.interop.domInfoHelper.getBoundingClientRect",
+ _ => true)
+ .SetResult(new AntDesign.JsInterop.DomRect());
+
+ // TextArea (AutoSize off) dereferences the text-area metrics on first render.
+ JSInterop
+ .Setup(
+ "AntDesign.interop.inputHelper.getTextAreaInfo",
+ _ => true)
+ .SetResult(new AntDesign.Internal.TextAreaInfo());
+
+ // Row (used internally by FormItem) dereferences the window dimensions on first render.
+ JSInterop
+ .Setup(
+ "AntDesign.interop.domInfoHelper.getWindow",
+ _ => true)
+ .SetResult(new AntDesign.JsInterop.Window());
+
+ // Admin pages are gated with [Authorize(Roles = Administrator)]. Provide an authenticated
+ // admin so AuthorizeView/cascading auth state behave as in a logged-in session.
+ var authContext = this.AddTestAuthorization();
+ authContext.SetAuthorized(TestConstants.AdminUserName);
+ authContext.SetRoles(RoleService.AdministratorRoleName);
+
+ // Register AntDesign in bUnit's own container so its services (ModalService, MessageService,
+ // ClientDimensionService, ...) resolve here and use bUnit's mock IJSRuntime. If they were
+ // resolved from the fallback (real server) container they would capture the circuit-bound
+ // RemoteJSRuntime and throw "JS interop calls cannot be issued at this time".
+ Services.AddAntDesign();
+
+ // Resolve domain services (GameService, EF, metadata, ...) not registered above from the
+ // real server container.
+ Services.AddFallbackServiceProvider(_scope.ServiceProvider);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ _scope.Dispose();
+
+ base.Dispose(disposing);
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Components/GameEditComponentTests.cs b/LANCommander.Server.UI.Tests/Components/GameEditComponentTests.cs
new file mode 100644
index 00000000..02703071
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Components/GameEditComponentTests.cs
@@ -0,0 +1,67 @@
+using Bunit;
+using LANCommander.Server.UI.Pages.Games.Edit;
+
+namespace LANCommander.Server.UI.Tests.Components;
+
+///
+/// bUnit component tests for the game edit "General" page. These replace the flaky
+/// Playwright equivalents in GameEditTests that asserted component behaviour
+/// (tab list, form fields, action buttons). Rendering happens in-process and synchronously,
+/// so there are no SignalR circuit races.
+///
+/// Tests that assert real routing/URL navigation (e.g. clicking a tab changes the address)
+/// remain in the Playwright smoke layer — bUnit renders a single component without a router.
+///
+[Collection("BUnit")]
+public class GameEditComponentTests : BUnitTestContext
+{
+ public GameEditComponentTests(BUnitServerFixture fixture) : base(fixture)
+ {
+ }
+
+ private IRenderedComponent RenderGeneral()
+ => RenderComponent(parameters => parameters
+ .Add(p => p.Id, Fixture.TestGameId));
+
+ [Fact]
+ public void GameEdit_LoadsSeededGameTitle()
+ {
+ var cut = RenderGeneral();
+
+ // The seeded game's title is bound into the title lookup input.
+ Assert.Contains(BUnitServerFixture.TestGameTitle, cut.Markup);
+ }
+
+ [Fact]
+ public void GameEdit_ShowsAllExpectedTabs()
+ {
+ var cut = RenderGeneral();
+
+ var menuText = cut.Markup;
+
+ foreach (var tab in new[] { "General", "Media", "Archives", "Actions", "Keys", "Scripts" })
+ {
+ Assert.Contains(tab, menuText);
+ }
+ }
+
+ [Fact]
+ public void GameEdit_HasSaveButton()
+ {
+ var cut = RenderGeneral();
+
+ Assert.Contains(
+ cut.FindAll("button"),
+ b => b.TextContent.Contains("Save", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void GameEdit_HasExportButton()
+ {
+ var cut = RenderGeneral();
+
+ Assert.Contains(
+ cut.FindAll("button"),
+ b => b.TextContent.Contains("Export", StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Components/MetadataComponentTests.cs b/LANCommander.Server.UI.Tests/Components/MetadataComponentTests.cs
new file mode 100644
index 00000000..90011b7e
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Components/MetadataComponentTests.cs
@@ -0,0 +1,79 @@
+using Bunit;
+using LANCommander.Server.Services;
+using Microsoft.Extensions.DependencyInjection;
+using TagsIndex = LANCommander.Server.UI.Pages.Metadata.Tags.Index;
+
+namespace LANCommander.Server.UI.Tests.Components;
+
+///
+/// bUnit component tests for the metadata (Tags) management page. Replaces the flaky
+/// Playwright MetadataTests CRUD flows. Rendering and the add-via-modal flow run
+/// synchronously in-process, removing the SignalR circuit races that made the modal +
+/// data-table-reload Playwright tests unreliable.
+///
+[Collection("BUnit")]
+public class MetadataComponentTests : BUnitTestContext
+{
+ public MetadataComponentTests(BUnitServerFixture fixture) : base(fixture)
+ {
+ }
+
+ private async Task ClearTagsAsync()
+ {
+ using var scope = Fixture.Factory.RealServices.CreateScope();
+ var tagService = scope.ServiceProvider.GetRequiredService();
+ foreach (var tag in await tagService.GetAsync())
+ await tagService.DeleteAsync(tag);
+ }
+
+ [Fact]
+ public async Task Tags_ShowsAddButton_AndEmptyState()
+ {
+ await ClearTagsAsync();
+
+ var cut = RenderComponent();
+
+ Assert.Contains(
+ cut.FindAll("button"),
+ b => b.TextContent.Contains("Add Tag", StringComparison.OrdinalIgnoreCase));
+
+ // The empty DataTable renders AntDesign's "No Data" placeholder once the async load
+ // completes.
+ cut.WaitForAssertion(
+ () => Assert.Contains("No Data", cut.Markup, StringComparison.OrdinalIgnoreCase),
+ timeout: TimeSpan.FromSeconds(10));
+ }
+
+ [Fact]
+ public async Task Tags_CanAddTag()
+ {
+ await ClearTagsAsync();
+
+ var cut = RenderComponent();
+
+ // Open the "New Tag" modal.
+ var addButton = cut.FindAll("button")
+ .First(b => b.TextContent.Contains("Add Tag", StringComparison.OrdinalIgnoreCase));
+ addButton.Click();
+
+ // Fill in the tag name inside the modal and confirm. AntDesign's Input commits its
+ // bound value on the change event, so dispatch both input and change.
+ var input = cut.WaitForElement(".ant-modal input", timeout: TimeSpan.FromSeconds(5));
+ input.Input("Action");
+ input.Change("Action");
+
+ var okButton = cut.FindAll(".ant-modal button")
+ .First(b => b.TextContent.Trim().Equals("OK", StringComparison.OrdinalIgnoreCase));
+ okButton.Click();
+
+ // The new tag is persisted and the data table reloads to show it.
+ cut.WaitForAssertion(
+ () => Assert.Contains("Action", cut.Markup),
+ timeout: TimeSpan.FromSeconds(10));
+
+ // Verify persistence at the service layer.
+ using var scope = Fixture.Factory.RealServices.CreateScope();
+ var tagService = scope.ServiceProvider.GetRequiredService();
+ Assert.Contains(await tagService.GetAsync(), t => t.Name == "Action");
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Components/ProfileComponentTests.cs b/LANCommander.Server.UI.Tests/Components/ProfileComponentTests.cs
new file mode 100644
index 00000000..0b877212
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Components/ProfileComponentTests.cs
@@ -0,0 +1,42 @@
+using Bunit;
+using ProfileIndex = LANCommander.Server.UI.Pages.Profile.Index;
+
+namespace LANCommander.Server.UI.Tests.Components;
+
+///
+/// bUnit component tests for the user profile page. Replaces the Playwright
+/// ProfileTests assertions that only verified the page renders the current
+/// user's details and form fields. Flows that depend on the logout redirect
+/// (update alias, change password) remain in the Playwright smoke layer.
+///
+[Collection("BUnit")]
+public class ProfileComponentTests : BUnitTestContext
+{
+ public ProfileComponentTests(BUnitServerFixture fixture) : base(fixture)
+ {
+ }
+
+ [Fact]
+ public void Profile_ShowsCurrentUsername()
+ {
+ var cut = RenderComponent();
+
+ // The authenticated admin's username is bound into the username input.
+ Assert.Contains(TestConstants.AdminUserName, cut.Markup);
+ }
+
+ [Fact]
+ public void Profile_ShowsFormElements()
+ {
+ var cut = RenderComponent();
+
+ foreach (var label in new[] { "Username", "Alias", "Email Address" })
+ {
+ Assert.Contains(label, cut.Markup);
+ }
+
+ Assert.Contains(
+ cut.FindAll("button"),
+ b => b.TextContent.Contains("Save", StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Components/RoleManagementComponentTests.cs b/LANCommander.Server.UI.Tests/Components/RoleManagementComponentTests.cs
new file mode 100644
index 00000000..db243130
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Components/RoleManagementComponentTests.cs
@@ -0,0 +1,36 @@
+using Bunit;
+using LANCommander.Server.Services;
+using RolesIndex = LANCommander.Server.UI.Pages.Settings.Roles.Index;
+
+namespace LANCommander.Server.UI.Tests.Components;
+
+///
+/// bUnit component tests for the role management page. Replaces the Playwright
+/// SettingsTests.SettingsRoles_ShowsRoleList assertion that the seeded
+/// Administrator role appears in the data table. Exercises the custom
+/// DataTable which loads its rows asynchronously after first render via
+/// the EF IDbContextFactory.
+///
+[Collection("BUnit")]
+public class RoleManagementComponentTests : BUnitTestContext
+{
+ public RoleManagementComponentTests(BUnitServerFixture fixture) : base(fixture)
+ {
+ }
+
+ [Fact]
+ public void Roles_ShowsAddRoleButtonAndAdministratorRole()
+ {
+ var cut = RenderComponent();
+
+ Assert.Contains(
+ cut.FindAll("button"),
+ b => b.TextContent.Contains("Add Role", StringComparison.OrdinalIgnoreCase));
+
+ // The DataTable loads rows asynchronously after the first render, so poll until the
+ // seeded Administrator role appears in the rendered markup.
+ cut.WaitForAssertion(
+ () => Assert.Contains(RoleService.AdministratorRoleName, cut.Markup),
+ timeout: TimeSpan.FromSeconds(10));
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Components/SettingsComponentTests.cs b/LANCommander.Server.UI.Tests/Components/SettingsComponentTests.cs
new file mode 100644
index 00000000..36a05639
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Components/SettingsComponentTests.cs
@@ -0,0 +1,29 @@
+using Bunit;
+using SettingsGeneral = LANCommander.Server.UI.Pages.Settings.General;
+
+namespace LANCommander.Server.UI.Tests.Components;
+
+///
+/// bUnit component tests for the admin Settings pages. Replaces the Playwright
+/// SettingsTests assertions that verified each settings page renders its
+/// expected form content. URL/routing assertions remain in the Playwright smoke layer.
+///
+[Collection("BUnit")]
+public class SettingsComponentTests : BUnitTestContext
+{
+ public SettingsComponentTests(BUnitServerFixture fixture) : base(fixture)
+ {
+ }
+
+ [Fact]
+ public void SettingsGeneral_ShowsFormElements()
+ {
+ var cut = RenderComponent();
+
+ Assert.Contains("Port", cut.Markup);
+ Assert.Contains("Use SSL", cut.Markup);
+ Assert.Contains(
+ cut.FindAll("button"),
+ b => b.TextContent.Contains("Save", StringComparison.OrdinalIgnoreCase));
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Components/UserManagementComponentTests.cs b/LANCommander.Server.UI.Tests/Components/UserManagementComponentTests.cs
new file mode 100644
index 00000000..a80b94ee
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Components/UserManagementComponentTests.cs
@@ -0,0 +1,29 @@
+using Bunit;
+using UsersIndex = LANCommander.Server.UI.Pages.Settings.Users.Index;
+
+namespace LANCommander.Server.UI.Tests.Components;
+
+///
+/// bUnit component tests for the user management page. Replaces the Playwright
+/// SettingsTests.SettingsUsers_ShowsUserList assertion that the seeded
+/// admin user appears in the data table.
+///
+[Collection("BUnit")]
+public class UserManagementComponentTests : BUnitTestContext
+{
+ public UserManagementComponentTests(BUnitServerFixture fixture) : base(fixture)
+ {
+ }
+
+ [Fact]
+ public void Users_ShowsSeededAdminUser()
+ {
+ var cut = RenderComponent();
+
+ // The DataTable loads rows asynchronously after first render; poll until the seeded
+ // admin user appears.
+ cut.WaitForAssertion(
+ () => Assert.Contains(TestConstants.AdminUserName, cut.Markup),
+ timeout: TimeSpan.FromSeconds(10));
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/ConfiguredServerFixture.cs b/LANCommander.Server.UI.Tests/ConfiguredServerFixture.cs
new file mode 100644
index 00000000..5d316ea6
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/ConfiguredServerFixture.cs
@@ -0,0 +1,125 @@
+using LANCommander.SDK.Enums;
+using LANCommander.Server.Data;
+using LANCommander.Server.Data.Models;
+using LANCommander.Server.Services;
+using LANCommander.Server.Settings.Enums;
+using LANCommander.Server.UI.Tests.Pages;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Playwright;
+
+namespace LANCommander.Server.UI.Tests;
+
+///
+/// Shared fixture that starts the server via WebApplicationFactory, programmatically creates
+/// the admin user, and makes it available for all tests in the collection.
+/// Shared across the "Server" collection via ICollectionFixture<ConfiguredServerFixture>.
+///
+public class ConfiguredServerFixture : IAsyncLifetime
+{
+ public PlaywrightFixture Playwright { get; private set; } = null!;
+ public UITestApplicationFactory Factory { get; private set; } = null!;
+
+ ///
+ /// ID of a game created via the service layer for edit tests.
+ ///
+ public Guid TestGameId { get; private set; }
+ public const string TestGameTitle = "Test Game";
+
+ public async Task InitializeAsync()
+ {
+ Playwright = new PlaywrightFixture();
+ await Playwright.InitializeAsync();
+
+ Factory = new UITestApplicationFactory();
+ // Trigger the factory to start the Kestrel server
+ _ = Factory.Services;
+
+ // Create the admin user via the service layer (before setting Provider
+ // so OnConfiguring doesn't try to add a conflicting SQLite provider)
+ using var scope = Factory.RealServices.CreateScope();
+ var roleService = scope.ServiceProvider.GetRequiredService();
+ var userService = scope.ServiceProvider.GetRequiredService();
+
+ await roleService.AddAsync(new Role { Name = RoleService.AdministratorRoleName });
+ var user = await userService.AddAsync(new User { UserName = TestConstants.AdminUserName });
+ await userService.ChangePassword(user.UserName, TestConstants.AdminPassword);
+ await userService.AddToRoleAsync(user.UserName, RoleService.AdministratorRoleName);
+
+ // Seed default storage locations so the import dialog can initialize
+ var storageLocationService = scope.ServiceProvider.GetRequiredService();
+ var archivePath = Path.Combine(Path.GetTempPath(), "LANCommander_UITest_Archives");
+ Directory.CreateDirectory(archivePath);
+ await storageLocationService.AddAsync(new StorageLocation
+ {
+ Path = archivePath,
+ Type = StorageLocationType.Archive,
+ Default = true
+ });
+
+ var savePath = Path.Combine(Path.GetTempPath(), "LANCommander_UITest_Saves");
+ Directory.CreateDirectory(savePath);
+ await storageLocationService.AddAsync(new StorageLocation
+ {
+ Path = savePath,
+ Type = StorageLocationType.Save,
+ Default = true
+ });
+
+ var mediaPath = Path.Combine(Path.GetTempPath(), "LANCommander_UITest_Media");
+ Directory.CreateDirectory(mediaPath);
+ await storageLocationService.AddAsync(new StorageLocation
+ {
+ Path = mediaPath,
+ Type = StorageLocationType.Media,
+ Default = true
+ });
+
+ // Seed a test game via the service layer for edit tests
+ var gameService = scope.ServiceProvider.GetRequiredService();
+ var game = await gameService.AddAsync(new Game
+ {
+ Title = TestGameTitle,
+ Type = GameType.MainGame,
+ Singleplayer = true
+ });
+ TestGameId = game.Id;
+
+ // Now set the database provider so the server doesn't redirect to /FirstTimeSetup
+ DatabaseContext.Provider = DatabaseProvider.SQLite;
+ }
+
+ public async Task DisposeAsync()
+ {
+ // Reset the static provider so other tests can use a fresh state
+ DatabaseContext.Provider = DatabaseProvider.Unknown;
+
+ await Factory.DisposeAsync();
+ await Playwright.DisposeAsync();
+ }
+
+ ///
+ /// Creates a new browser context and page, already logged in as admin.
+ ///
+ public async Task<(IBrowserContext Context, IPage Page)> CreateLoggedInPageAsync()
+ {
+ var context = await Playwright.NewContextAsync(Factory.BaseAddress);
+ var page = await context.NewPageAsync();
+
+ var loginPage = new LoginPage(page);
+ await loginPage.NavigateAsync();
+ await loginPage.LoginAsync(TestConstants.AdminUserName, TestConstants.AdminPassword);
+ await page.WaitForSelectorAsync("text=Dashboard", new() { Timeout = 15000 });
+
+ return (context, page);
+ }
+
+ ///
+ /// Creates a new browser context and page (not logged in).
+ ///
+ public async Task<(IBrowserContext Context, IPage Page)> CreateAnonymousPageAsync()
+ {
+ var context = await Playwright.NewContextAsync(Factory.BaseAddress);
+ var page = await context.NewPageAsync();
+ return (context, page);
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/FreshServerFixture.cs b/LANCommander.Server.UI.Tests/FreshServerFixture.cs
new file mode 100644
index 00000000..aa61e68c
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/FreshServerFixture.cs
@@ -0,0 +1,36 @@
+using Microsoft.Playwright;
+
+namespace LANCommander.Server.UI.Tests;
+
+///
+/// Fixture for FirstTimeSetupTests that provides a fresh unconfigured server.
+/// Unlike ConfiguredServerFixture, this does NOT create an admin user or set DatabaseContext.Provider,
+/// so the server will redirect to /FirstTimeSetup.
+///
+public class FreshServerFixture : IAsyncLifetime
+{
+ public PlaywrightFixture Playwright { get; private set; } = null!;
+ public UITestApplicationFactory Factory { get; private set; } = null!;
+
+ public async Task InitializeAsync()
+ {
+ Playwright = new PlaywrightFixture();
+ await Playwright.InitializeAsync();
+
+ Factory = new UITestApplicationFactory();
+ _ = Factory.Services;
+ }
+
+ public async Task DisposeAsync()
+ {
+ await Factory.DisposeAsync();
+ await Playwright.DisposeAsync();
+ }
+
+ public async Task<(IBrowserContext Context, IPage Page)> CreatePageAsync()
+ {
+ var context = await Playwright.NewContextAsync(Factory.BaseAddress);
+ var page = await context.NewPageAsync();
+ return (context, page);
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/LANCommander.Server.UI.Tests.csproj b/LANCommander.Server.UI.Tests/LANCommander.Server.UI.Tests.csproj
new file mode 100644
index 00000000..de2292f3
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/LANCommander.Server.UI.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LANCommander.Server.UI.Tests/Pages/AdminDashboardPage.cs b/LANCommander.Server.UI.Tests/Pages/AdminDashboardPage.cs
new file mode 100644
index 00000000..c8de9351
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Pages/AdminDashboardPage.cs
@@ -0,0 +1,101 @@
+using Microsoft.Playwright;
+
+namespace LANCommander.Server.UI.Tests.Pages;
+
+///
+/// Page object for the admin dashboard and navigation.
+///
+public class AdminDashboardPage
+{
+ private readonly IPage _page;
+
+ public AdminDashboardPage(IPage page)
+ {
+ _page = page;
+ }
+
+ public async Task IsDisplayedAsync()
+ {
+ return await _page.GetByText("Dashboard").First.IsVisibleAsync();
+ }
+
+ public async Task GetPageTitleAsync()
+ {
+ return await _page.TitleAsync();
+ }
+
+ // Navigation helpers
+ public async Task NavigateToGamesAsync()
+ {
+ await _page.GetByRole(AriaRole.Link, new() { Name = "Games" }).ClickAsync();
+ await _page.WaitForURLAsync("**/Games", new() { Timeout = 10000 });
+ // Wait for Blazor to render the page content
+ await _page.WaitForSelectorAsync("text=Add Game", new() { Timeout = 10000 });
+ }
+
+ public async Task NavigateToSettingsGeneralAsync()
+ {
+ // Navigate directly to the route rather than expanding the Settings SubMenu flyout.
+ // The nested submenu expansion depends on the Blazor Server circuit being responsive
+ // and is unreliable in CI; a direct navigation still exercises routing + auth.
+ await _page.GotoAsync("/Settings/General");
+ await _page.WaitForURLAsync("**/Settings/General", new() { Timeout = 10000 });
+ // Wait for Blazor to render the settings content
+ await _page.WaitForSelectorAsync("text=Use SSL", new() { Timeout = 10000 });
+ }
+
+ public async Task NavigateToRedistributablesAsync()
+ {
+ await _page.GetByRole(AriaRole.Link, new() { Name = "Redistributables" }).ClickAsync();
+ await _page.WaitForURLAsync("**/Redistributables", new() { Timeout = 10000 });
+ }
+
+ public async Task NavigateToServersAsync()
+ {
+ await _page.GetByRole(AriaRole.Link, new() { Name = "Servers", Exact = true }).ClickAsync();
+ await _page.WaitForURLAsync("**/Servers", new() { Timeout = 10000 });
+ }
+
+ public async Task NavigateToIssuesAsync()
+ {
+ await _page.GetByRole(AriaRole.Link, new() { Name = "Issues" }).ClickAsync();
+ await _page.WaitForURLAsync("**/Issues", new() { Timeout = 10000 });
+ }
+
+ public async Task NavigateToFilesAsync()
+ {
+ await _page.GetByRole(AriaRole.Link, new() { Name = "Files" }).ClickAsync();
+ await _page.WaitForURLAsync("**/Files", new() { Timeout = 10000 });
+ }
+
+ public async Task NavigateToToolsAsync()
+ {
+ await _page.GetByRole(AriaRole.Link, new() { Name = "Tools", Exact = true }).ClickAsync();
+ await _page.WaitForURLAsync("**/Tools", new() { Timeout = 10000 });
+ }
+
+ ///
+ /// Gets the visible menu items from the sidebar navigation.
+ ///
+ public async Task> GetMainMenuItemsAsync()
+ {
+ // Wait for sidebar menu items to render
+ await _page.GetByRole(AriaRole.Complementary)
+ .Locator("[role='menuitem']")
+ .First
+ .WaitForAsync(new() { Timeout = 10000 });
+
+ var menuItems = _page.GetByRole(AriaRole.Complementary).Locator("[role='menuitem']");
+ var count = await menuItems.CountAsync();
+ var items = new List();
+
+ for (int i = 0; i < count; i++)
+ {
+ var text = await menuItems.Nth(i).TextContentAsync();
+ if (!string.IsNullOrWhiteSpace(text))
+ items.Add(text.Trim());
+ }
+
+ return items;
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Pages/FirstTimeSetupPage.cs b/LANCommander.Server.UI.Tests/Pages/FirstTimeSetupPage.cs
new file mode 100644
index 00000000..8af8df55
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Pages/FirstTimeSetupPage.cs
@@ -0,0 +1,94 @@
+using Microsoft.Playwright;
+
+namespace LANCommander.Server.UI.Tests.Pages;
+
+///
+/// Page object for the First Time Setup wizard at /FirstTimeSetup.
+///
+public class FirstTimeSetupPage
+{
+ private readonly IPage _page;
+
+ public FirstTimeSetupPage(IPage page)
+ {
+ _page = page;
+ }
+
+ public async Task NavigateAsync()
+ {
+ await _page.GotoAsync("/FirstTimeSetup");
+ // Wait for Blazor to render the page
+ await _page.WaitForSelectorAsync("text=First Time Setup", new() { Timeout = 10000 });
+ }
+
+ public async Task IsDisplayedAsync()
+ {
+ return await _page.GetByText("First Time Setup").IsVisibleAsync();
+ }
+
+ // Step 1: Database
+ public async Task SelectDatabaseProviderAsync(string provider)
+ {
+ await _page.GetByRole(AriaRole.Combobox).ClickAsync();
+ await _page.GetByRole(AriaRole.Option, new() { Name = provider }).ClickAsync();
+ }
+
+ public async Task ClickConnectAsync()
+ {
+ await _page.GetByRole(AriaRole.Button, new() { Name = "Connect" }).ClickAsync();
+ }
+
+ public async Task CompleteDatabaseStepAsync(string provider = "SQLite")
+ {
+ await SelectDatabaseProviderAsync(provider);
+ await ClickConnectAsync();
+ // Wait for navigation to paths step
+ await _page.WaitForURLAsync("**/FirstTimeSetup/Paths", new() { Timeout = 30000 });
+ }
+
+ // Step 2: Paths
+ public async Task CompletePathsStepAsync()
+ {
+ await _page.GetByRole(AriaRole.Button, new() { Name = "Next" }).ClickAsync();
+ await _page.WaitForURLAsync("**/FirstTimeSetup/Metadata", new() { Timeout = 10000 });
+ }
+
+ // Step 3: Metadata
+ public async Task CompleteMetadataStepAsync()
+ {
+ await _page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
+ await _page.WaitForURLAsync("**/FirstTimeSetup/Administrator", new() { Timeout = 10000 });
+ }
+
+ // Step 4: Administrator
+ public async Task CreateAdministratorAsync(string username, string password)
+ {
+ // AntDesign doesn't use standard label/for associations, so use role-based selection
+ // Username is the first textbox on the Administrator step
+ await _page.WaitForSelectorAsync("text=To get started", new() { Timeout = 10000 });
+ await _page.GetByRole(AriaRole.Textbox).First.FillAsync(username);
+ await _page.Locator("input[name='context.Password']").FillAsync(password);
+ await _page.Locator("input[name='context.PasswordConfirm']").FillAsync(password);
+ await _page.GetByRole(AriaRole.Button, new() { Name = "Create" }).ClickAsync();
+ }
+
+ ///
+ /// Completes the entire first-time setup wizard from start to finish.
+ ///
+ public async Task CompleteFullSetupAsync(
+ string adminUsername = "admin",
+ string adminPassword = "Password1234!",
+ string databaseProvider = "SQLite")
+ {
+ await CompleteDatabaseStepAsync(databaseProvider);
+ await CompletePathsStepAsync();
+ await CompleteMetadataStepAsync();
+ await CreateAdministratorAsync(adminUsername, adminPassword);
+
+ // Wait for the success message
+ await _page.WaitForSelectorAsync("text=Setup completed", new() { Timeout = 15000 });
+
+ // Wait for redirect to login page (may already have happened)
+ await _page.WaitForSelectorAsync("text=User Name", new() { Timeout = 15000 });
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Pages/LoginPage.cs b/LANCommander.Server.UI.Tests/Pages/LoginPage.cs
new file mode 100644
index 00000000..f95a8cfd
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Pages/LoginPage.cs
@@ -0,0 +1,46 @@
+using Microsoft.Playwright;
+
+namespace LANCommander.Server.UI.Tests.Pages;
+
+///
+/// Page object for the login page at /Login.
+///
+public class LoginPage
+{
+ private readonly IPage _page;
+
+ public LoginPage(IPage page)
+ {
+ _page = page;
+ }
+
+ public async Task NavigateAsync()
+ {
+ await _page.GotoAsync("/Login");
+ }
+
+ public async Task IsDisplayedAsync()
+ {
+ return await _page.GetByRole(AriaRole.Textbox, new() { Name = "User Name" }).IsVisibleAsync();
+ }
+
+ public async Task LoginAsync(string username, string password)
+ {
+ await _page.GetByRole(AriaRole.Textbox, new() { Name = "User Name" }).FillAsync(username);
+ await _page.GetByRole(AriaRole.Textbox, new() { Name = "Password" }).FillAsync(password);
+ await _page.GetByRole(AriaRole.Button, new() { Name = "Login" }).ClickAsync();
+ }
+
+ public async Task GetErrorMessageAsync()
+ {
+ var errorLocator = _page.GetByText("Invalid login attempt.");
+ if (await errorLocator.IsVisibleAsync())
+ return await errorLocator.TextContentAsync();
+ return null;
+ }
+
+ public async Task HasRegisterLinkAsync()
+ {
+ return await _page.GetByRole(AriaRole.Link, new() { Name = "Register" }).IsVisibleAsync();
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/PlaywrightFixture.cs b/LANCommander.Server.UI.Tests/PlaywrightFixture.cs
new file mode 100644
index 00000000..ddcb9199
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/PlaywrightFixture.cs
@@ -0,0 +1,37 @@
+using Microsoft.Playwright;
+
+namespace LANCommander.Server.UI.Tests;
+
+///
+/// Shared Playwright fixture that manages browser lifetime across all tests in the collection.
+/// Starts the server process and initializes Playwright once per test run.
+///
+public class PlaywrightFixture : IAsyncLifetime
+{
+ public IPlaywright Playwright { get; private set; } = null!;
+ public IBrowser Browser { get; private set; } = null!;
+
+ public async Task InitializeAsync()
+ {
+ Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
+ Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
+ {
+ Headless = true,
+ });
+ }
+
+ public async Task DisposeAsync()
+ {
+ await Browser.DisposeAsync();
+ Playwright.Dispose();
+ }
+
+ public async Task NewContextAsync(string baseUrl)
+ {
+ return await Browser.NewContextAsync(new BrowserNewContextOptions
+ {
+ IgnoreHTTPSErrors = true,
+ BaseURL = baseUrl,
+ });
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/ScreenshotHelper.cs b/LANCommander.Server.UI.Tests/ScreenshotHelper.cs
new file mode 100644
index 00000000..1863b27d
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/ScreenshotHelper.cs
@@ -0,0 +1,69 @@
+using System.Reflection;
+using Microsoft.Playwright;
+using Xunit.Abstractions;
+
+namespace LANCommander.Server.UI.Tests;
+
+///
+/// Captures a full-page screenshot of the final page state at the end of each test.
+/// Screenshots are saved to a "Screenshots" directory that CI uploads as an artifact,
+/// making failures easy to diagnose. (xUnit v2 does not expose the test outcome to
+/// DisposeAsync, so we capture unconditionally and name each file after the test.)
+///
+public static class ScreenshotHelper
+{
+ private static readonly string ScreenshotDir = Path.Combine(
+ Environment.GetEnvironmentVariable("SCREENSHOT_DIR")
+ ?? Path.Combine(AppContext.BaseDirectory, "Screenshots"),
+ string.Empty);
+
+ ///
+ /// Captures a screenshot of the current page state, named after the running test.
+ /// Call this from DisposeAsync — it extracts the test name from ITestOutputHelper.
+ ///
+ public static async Task CaptureAsync(IPage? page, ITestOutputHelper? output)
+ {
+ if (page == null || output == null)
+ return;
+
+ var testName = GetTestDisplayName(output) ?? $"Unknown_{Guid.NewGuid():N}";
+
+ try
+ {
+ Directory.CreateDirectory(ScreenshotDir);
+ var safeName = string.Join("_", testName.Split(Path.GetInvalidFileNameChars()));
+ var path = Path.Combine(ScreenshotDir, $"{safeName}.png");
+ await page.ScreenshotAsync(new PageScreenshotOptions
+ {
+ Path = path,
+ FullPage = true
+ });
+ }
+ catch
+ {
+ // Best effort — don't fail the test because of screenshot capture
+ }
+ }
+
+ ///
+ /// Extracts the test display name from xUnit's ITestOutputHelper via reflection.
+ ///
+ private static string? GetTestDisplayName(ITestOutputHelper output)
+ {
+ try
+ {
+ var type = output.GetType();
+ var testField = type.GetField("test", BindingFlags.Instance | BindingFlags.NonPublic);
+ if (testField == null)
+ return null;
+
+ var test = testField.GetValue(output);
+ var displayNameProp = test?.GetType().GetProperty("DisplayName");
+ return displayNameProp?.GetValue(test) as string;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/ServerCollection.cs b/LANCommander.Server.UI.Tests/ServerCollection.cs
new file mode 100644
index 00000000..bb1108ac
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/ServerCollection.cs
@@ -0,0 +1,20 @@
+namespace LANCommander.Server.UI.Tests;
+
+///
+/// xUnit collection definition that shares a single ConfiguredServerFixture across all
+/// test classes in the "Server" collection. This avoids creating multiple WebApplicationFactory
+/// instances that fight over the static DatabaseContext.Provider.
+///
+[CollectionDefinition("Server")]
+public class ServerCollection : ICollectionFixture
+{
+}
+
+///
+/// Separate collection for FirstTimeSetupTests which needs its own unconfigured server.
+/// Having it in its own collection ensures it doesn't share state with the Server collection.
+///
+[CollectionDefinition("FirstTimeSetup")]
+public class FirstTimeSetupCollection : ICollectionFixture
+{
+}
diff --git a/LANCommander.Server.UI.Tests/TestConstants.cs b/LANCommander.Server.UI.Tests/TestConstants.cs
new file mode 100644
index 00000000..6c4f0d95
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/TestConstants.cs
@@ -0,0 +1,7 @@
+namespace LANCommander.Server.UI.Tests;
+
+public static class TestConstants
+{
+ public const string AdminUserName = "admin";
+ public const string AdminPassword = "Password1234!";
+}
diff --git a/LANCommander.Server.UI.Tests/Tests/AdminNavigationTests.cs b/LANCommander.Server.UI.Tests/Tests/AdminNavigationTests.cs
new file mode 100644
index 00000000..a73f887b
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Tests/AdminNavigationTests.cs
@@ -0,0 +1,139 @@
+using LANCommander.Server.UI.Tests.Pages;
+using Microsoft.Playwright;
+using Xunit.Abstractions;
+
+namespace LANCommander.Server.UI.Tests.Tests;
+
+///
+/// Tests for navigating around key parts of the admin application.
+/// These tests verify that the main admin pages are accessible and render correctly
+/// after logging in as an administrator.
+/// Uses the shared "Server" collection fixture so the server starts once for the whole collection.
+///
+[Collection("Server")]
+public class AdminNavigationTests : IAsyncLifetime
+{
+ private readonly ConfiguredServerFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private IBrowserContext _context = null!;
+ private IPage _page = null!;
+
+ public AdminNavigationTests(ConfiguredServerFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ public async Task InitializeAsync()
+ {
+ (_context, _page) = await _fixture.CreateLoggedInPageAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await ScreenshotHelper.CaptureAsync(_page, _output);
+ if (_page != null) await _page.CloseAsync();
+ if (_context != null) await _context.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task Dashboard_ShowsOverviewWithCharts()
+ {
+ var dashboard = new AdminDashboardPage(_page);
+ Assert.True(await dashboard.IsDisplayedAsync());
+
+ // Dashboard should show playtime charts
+ await Assertions.Expect(_page.GetByText("Top 10 Total Playtime (By Player)")).ToBeVisibleAsync();
+ await Assertions.Expect(_page.GetByText("Top 10 Total Playtime (By Game)")).ToBeVisibleAsync();
+ await Assertions.Expect(_page.GetByText("Top Average Session Length (By Game)")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task Navigation_SidebarShowsExpectedMenuItems()
+ {
+ var dashboard = new AdminDashboardPage(_page);
+ var menuItems = await dashboard.GetMainMenuItemsAsync();
+
+ Assert.Contains(menuItems, m => m.Contains("Dashboards"));
+ Assert.Contains(menuItems, m => m.Contains("Games"));
+ Assert.Contains(menuItems, m => m.Contains("Redistributables"));
+ Assert.Contains(menuItems, m => m.Contains("Tools"));
+ Assert.Contains(menuItems, m => m.Contains("Servers"));
+ Assert.Contains(menuItems, m => m.Contains("Issues"));
+ Assert.Contains(menuItems, m => m.Contains("Files"));
+ Assert.Contains(menuItems, m => m.Contains("Settings"));
+ }
+
+ [Fact]
+ public async Task GamesPage_ShowsEmptyTable()
+ {
+ var dashboard = new AdminDashboardPage(_page);
+ await dashboard.NavigateToGamesAsync();
+
+ Assert.Contains("/Games", _page.Url);
+ await Assertions.Expect(_page.GetByText("Games").First).ToBeVisibleAsync();
+ await Assertions.Expect(_page.GetByRole(AriaRole.Button, new() { Name = "Add Game" })).ToBeVisibleAsync();
+ await Assertions.Expect(_page.GetByRole(AriaRole.Button, new() { Name = "Import" })).ToBeVisibleAsync();
+ // Empty table should show "No data"
+ await Assertions.Expect(_page.GetByText("No data")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task RedistributablesPage_IsAccessible()
+ {
+ var dashboard = new AdminDashboardPage(_page);
+ await dashboard.NavigateToRedistributablesAsync();
+
+ Assert.Contains("/Redistributables", _page.Url);
+ await Assertions.Expect(_page.GetByText("Redistributables").First).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task ToolsPage_IsAccessible()
+ {
+ var dashboard = new AdminDashboardPage(_page);
+ await dashboard.NavigateToToolsAsync();
+
+ Assert.Contains("/Tools", _page.Url);
+ await Assertions.Expect(_page.GetByText("Tools").First).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task ServersPage_IsAccessible()
+ {
+ var dashboard = new AdminDashboardPage(_page);
+ await dashboard.NavigateToServersAsync();
+
+ Assert.Contains("/Servers", _page.Url);
+ await Assertions.Expect(_page.GetByText("Servers").First).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task IssuesPage_IsAccessible()
+ {
+ var dashboard = new AdminDashboardPage(_page);
+ await dashboard.NavigateToIssuesAsync();
+
+ Assert.Contains("/Issues", _page.Url);
+ }
+
+ [Fact]
+ public async Task FilesPage_IsAccessible()
+ {
+ var dashboard = new AdminDashboardPage(_page);
+ await dashboard.NavigateToFilesAsync();
+
+ Assert.Contains("/Files", _page.Url);
+ }
+
+ [Fact]
+ public async Task SettingsGeneralPage_IsAccessible()
+ {
+ var dashboard = new AdminDashboardPage(_page);
+ await dashboard.NavigateToSettingsGeneralAsync();
+
+ Assert.Contains("/Settings/General", _page.Url);
+ // Verify settings-specific content is visible ("Use SSL" is unique to General settings)
+ await Assertions.Expect(_page.GetByText("Use SSL")).ToBeVisibleAsync();
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Tests/FirstTimeSetupTests.cs b/LANCommander.Server.UI.Tests/Tests/FirstTimeSetupTests.cs
new file mode 100644
index 00000000..adcec7bd
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Tests/FirstTimeSetupTests.cs
@@ -0,0 +1,107 @@
+using LANCommander.Server.UI.Tests.Pages;
+using Microsoft.Playwright;
+using Xunit.Abstractions;
+
+namespace LANCommander.Server.UI.Tests.Tests;
+
+///
+/// Tests for the first-time setup wizard when the server has no existing configuration.
+/// Runs in its own collection to avoid conflicts with ConfiguredServerFixture over
+/// the static DatabaseContext.Provider.
+///
+[Collection("FirstTimeSetup")]
+public class FirstTimeSetupTests : IAsyncLifetime
+{
+ private readonly FreshServerFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private IBrowserContext _context = null!;
+ private IPage _page = null!;
+
+ public FirstTimeSetupTests(FreshServerFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ public async Task InitializeAsync()
+ {
+ (_context, _page) = await _fixture.CreatePageAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await ScreenshotHelper.CaptureAsync(_page, _output);
+ if (_page != null) await _page.CloseAsync();
+ if (_context != null) await _context.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task FreshServer_RedirectsToFirstTimeSetup()
+ {
+ await _page.GotoAsync("/");
+
+ // A fresh server should show the First Time Setup page
+ await _page.WaitForSelectorAsync("text=First Time Setup", new() { Timeout = 10000 });
+
+ var setupPage = new FirstTimeSetupPage(_page);
+ Assert.True(await setupPage.IsDisplayedAsync());
+ }
+
+ [Fact]
+ public async Task FirstTimeSetup_ShowsFourSteps()
+ {
+ var setupPage = new FirstTimeSetupPage(_page);
+ await setupPage.NavigateAsync();
+
+ // Verify all 4 steps are visible in the wizard
+ await Assertions.Expect(_page.GetByText("Database").First).ToBeVisibleAsync();
+ await Assertions.Expect(_page.GetByText("Paths")).ToBeVisibleAsync();
+ // "Metadata" may be truncated in UI to "Metad" but the text node still exists
+ await Assertions.Expect(_page.Locator("text=/Metad/").First).ToBeVisibleAsync();
+ await Assertions.Expect(_page.GetByText("Administrator")).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task FirstTimeSetup_DatabaseStep_ShowsProviderOptions()
+ {
+ var setupPage = new FirstTimeSetupPage(_page);
+ await setupPage.NavigateAsync();
+
+ // Open the database provider dropdown
+ await _page.GetByRole(AriaRole.Combobox).ClickAsync();
+
+ // Wait for the dropdown listbox to appear
+ await _page.WaitForSelectorAsync("[role='listbox']", new() { Timeout = 5000 });
+
+ // Verify all expected providers are shown
+ await Assertions.Expect(_page.GetByRole(AriaRole.Option, new() { Name = "SQLite" })).ToBeVisibleAsync();
+ await Assertions.Expect(_page.GetByRole(AriaRole.Option, new() { Name = "MySQL" })).ToBeVisibleAsync();
+ await Assertions.Expect(_page.GetByRole(AriaRole.Option, new() { Name = "PostgreSQL" })).ToBeVisibleAsync();
+ }
+
+ [Fact(Skip = "Requires real database and file I/O - not supported with in-memory WebApplicationFactory")]
+ public async Task FirstTimeSetup_CompleteWizardAndLogin()
+ {
+ var setupPage = new FirstTimeSetupPage(_page);
+ await setupPage.NavigateAsync();
+
+ await setupPage.CompleteFullSetupAsync(
+ adminUsername: TestConstants.AdminUserName,
+ adminPassword: TestConstants.AdminPassword);
+
+ // After setup, we should be on the login page
+ Assert.Contains("/Login", _page.Url);
+
+ var loginPage = new LoginPage(_page);
+ Assert.True(await loginPage.IsDisplayedAsync());
+
+ // Now log in with the admin credentials that were just created
+ await loginPage.LoginAsync(TestConstants.AdminUserName, TestConstants.AdminPassword);
+
+ // Wait for the Blazor app to render the dashboard
+ await _page.WaitForSelectorAsync("text=Dashboard", new() { Timeout = 15000 });
+
+ var dashboard = new AdminDashboardPage(_page);
+ Assert.True(await dashboard.IsDisplayedAsync());
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/Tests/LoginTests.cs b/LANCommander.Server.UI.Tests/Tests/LoginTests.cs
new file mode 100644
index 00000000..f6fc544a
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/Tests/LoginTests.cs
@@ -0,0 +1,102 @@
+using LANCommander.Server.UI.Tests.Pages;
+using Microsoft.Playwright;
+using Xunit.Abstractions;
+
+namespace LANCommander.Server.UI.Tests.Tests;
+
+///
+/// Tests for the login flow against a server that has already been configured.
+/// These tests assume the server is running with a known admin user.
+/// Uses the shared "Server" collection fixture so the server starts once for the whole collection.
+///
+[Collection("Server")]
+public class LoginTests : IAsyncLifetime
+{
+ private readonly ConfiguredServerFixture _fixture;
+ private readonly ITestOutputHelper _output;
+ private IBrowserContext _context = null!;
+ private IPage _page = null!;
+
+ public LoginTests(ConfiguredServerFixture fixture, ITestOutputHelper output)
+ {
+ _fixture = fixture;
+ _output = output;
+ }
+
+ public async Task InitializeAsync()
+ {
+ (_context, _page) = await _fixture.CreateAnonymousPageAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ await ScreenshotHelper.CaptureAsync(_page, _output);
+ if (_page != null) await _page.CloseAsync();
+ if (_context != null) await _context.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task UnauthenticatedUser_RedirectsToLogin()
+ {
+ await _page.GotoAsync("/");
+
+ Assert.Contains("/Login", _page.Url);
+
+ var loginPage = new LoginPage(_page);
+ Assert.True(await loginPage.IsDisplayedAsync());
+ }
+
+ [Fact]
+ public async Task LoginPage_ShowsExpectedElements()
+ {
+ var loginPage = new LoginPage(_page);
+ await loginPage.NavigateAsync();
+
+ Assert.True(await loginPage.IsDisplayedAsync());
+ Assert.True(await loginPage.HasRegisterLinkAsync());
+ await Assertions.Expect(_page.GetByRole(AriaRole.Button, new() { Name = "Login" })).ToBeVisibleAsync();
+ }
+
+ [Fact]
+ public async Task Login_WithValidCredentials_RedirectsToDashboard()
+ {
+ var loginPage = new LoginPage(_page);
+ await loginPage.NavigateAsync();
+
+ await loginPage.LoginAsync(TestConstants.AdminUserName, TestConstants.AdminPassword);
+
+ // Should redirect to dashboard
+ await _page.WaitForSelectorAsync("text=Dashboard", new() { Timeout = 15000 });
+
+ var dashboard = new AdminDashboardPage(_page);
+ Assert.True(await dashboard.IsDisplayedAsync());
+ }
+
+ [Fact]
+ public async Task Login_WithInvalidCredentials_ShowsError()
+ {
+ var loginPage = new LoginPage(_page);
+ await loginPage.NavigateAsync();
+
+ await loginPage.LoginAsync("admin", "WrongPassword123!");
+
+ // Should stay on login page with error message
+ await _page.WaitForSelectorAsync("text=Invalid login attempt.", new() { Timeout = 5000 });
+
+ var error = await loginPage.GetErrorMessageAsync();
+ Assert.NotNull(error);
+ Assert.Contains("Invalid login attempt", error);
+ }
+
+ [Fact]
+ public async Task Login_WithEmptyCredentials_StaysOnLoginPage()
+ {
+ var loginPage = new LoginPage(_page);
+ await loginPage.NavigateAsync();
+
+ await loginPage.LoginAsync("", "");
+
+ // Should remain on login page
+ Assert.Contains("/Login", _page.Url);
+ }
+}
diff --git a/LANCommander.Server.UI.Tests/UITestApplicationFactory.cs b/LANCommander.Server.UI.Tests/UITestApplicationFactory.cs
new file mode 100644
index 00000000..5148a9c8
--- /dev/null
+++ b/LANCommander.Server.UI.Tests/UITestApplicationFactory.cs
@@ -0,0 +1,225 @@
+using System.Data.Common;
+using LANCommander.Server.Data;
+using Microsoft.Data.Sqlite;
+using LANCommander.Server.Services.Abstractions;
+using LANCommander.Server.Settings.Enums;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Octokit;
+using Semver;
+
+namespace LANCommander.Server.UI.Tests;
+
+///
+/// WebApplicationFactory that starts a real Kestrel server for Playwright browser tests.
+/// Uses the "dual host" pattern: builds the real app with Kestrel from the configured
+/// builder, and returns a dummy TestServer host to satisfy WebApplicationFactory's internals.
+/// In .NET 9, WebApplicationFactory hard-casts IServer to TestServer, so we need this workaround.
+///
+public class UITestApplicationFactory : WebApplicationFactory
+{
+ private IHost? _realHost;
+ private string? _dbPath;
+ public string BaseAddress { get; private set; } = default!;
+ public IServiceProvider RealServices => _realHost!.Services;
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ // Set the content root to the server project directory so static files are found
+ builder.UseContentRoot(FindServerProjectDirectory());
+
+ // The login page uses relative paths for screenshot backgrounds.
+ // Create the expected directory so it doesn't throw DirectoryNotFoundException.
+ Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory, "wwwroot", "static", "login"));
+
+ builder.ConfigureServices(services =>
+ {
+ // Replace database with in-memory (same pattern as existing ApplicationFactory)
+ var dbContextDescriptor = services.SingleOrDefault(
+ d => d.ServiceType == typeof(IDbContextOptionsConfiguration));
+ if (dbContextDescriptor != null) services.Remove(dbContextDescriptor);
+
+ var dbConnectionDescriptor = services.SingleOrDefault(
+ d => d.ServiceType == typeof(DbConnection));
+ if (dbConnectionDescriptor != null) services.Remove(dbConnectionDescriptor);
+
+ // Use a file-based SQLite database rather than the EF InMemory provider.
+ // The app's DataTable queries use relational features (AsSplitQuery, Include,
+ // and a translated punctuation-stripping search expression) that the InMemory
+ // provider cannot translate — on CI this surfaced as a native stack overflow in
+ // CountAsync that crashed the in-process server and cascaded into timeouts.
+ // A real SQLite file supports those queries and concurrent connections.
+ _dbPath = Path.Combine(Path.GetTempPath(), $"LANCommander_UITest_{Guid.NewGuid():N}.db");
+
+ services.AddDbContextFactory(optionsBuilder =>
+ {
+ optionsBuilder.UseSqlite(
+ $"Data Source={_dbPath}",
+ options => options.MigrationsAssembly("LANCommander.Server.Data.SQLite"));
+ });
+
+ // Mock IVersionProvider
+ var versionProviderDescriptor = services.SingleOrDefault(
+ d => typeof(IVersionProvider).IsAssignableFrom(d.ServiceType));
+ if (versionProviderDescriptor != null) services.Remove(versionProviderDescriptor);
+ services.AddSingleton();
+
+ // Mock IGitHubService
+ var gitHubServiceDescriptor = services.SingleOrDefault(
+ d => typeof(IGitHubService).IsAssignableFrom(d.ServiceType));
+ if (gitHubServiceDescriptor != null) services.Remove(gitHubServiceDescriptor);
+ services.AddSingleton();
+
+ // Remove Hangfire hosted services to prevent stack overflow during process shutdown.
+ // The Hangfire background job server has a deep disposal chain that can overflow the stack.
+ var hangfireHostedServices = services.Where(
+ d => d.ServiceType == typeof(Microsoft.Extensions.Hosting.IHostedService)
+ && d.ImplementationType?.FullName?.Contains("Hangfire") == true).ToList();
+ foreach (var svc in hangfireHostedServices) services.Remove(svc);
+ });
+ }
+
+ protected override IHost CreateHost(IHostBuilder builder)
+ {
+ // Build the REAL host with Kestrel (the builder has all configured services from Program.cs).
+ // Use explicit ListenLocalhost(0) to override the URL-based configuration from Program.cs.
+ builder.ConfigureWebHost(wb =>
+ {
+ wb.UseKestrel(options =>
+ {
+ options.Listen(System.Net.IPAddress.Loopback, 0);
+ });
+ });
+ _realHost = builder.Build();
+ _realHost.Start();
+
+ // Create the SQLite schema from the current model before any requests run.
+ using (var scope = _realHost.Services.CreateScope())
+ {
+ var contextFactory = scope.ServiceProvider
+ .GetRequiredService>();
+ using var context = contextFactory.CreateDbContext();
+ context.Database.EnsureCreated();
+ }
+
+ // Get the dynamically assigned port
+ var server = _realHost.Services.GetRequiredService();
+ var addresses = server.Features.Get();
+ BaseAddress = addresses!.Addresses.First();
+
+ // Create a DUMMY host with TestServer to satisfy WebApplicationFactory's internal cast.
+ // WebApplicationFactory in .NET 9 hard-casts IServer to TestServer after CreateHost returns.
+ var dummyBuilder = new HostBuilder();
+ dummyBuilder.ConfigureWebHost(wb =>
+ {
+ wb.UseTestServer();
+ wb.Configure(app => { });
+ });
+ var dummyHost = dummyBuilder.Build();
+ dummyHost.Start();
+
+ return dummyHost;
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ if (_realHost != null)
+ {
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+ await _realHost.StopAsync(cts.Token);
+ }
+ catch
+ {
+ // Suppress shutdown errors
+ }
+
+ // Do NOT call _realHost.Dispose() — the DI container's deep dependency
+ // chain (Hangfire, EF, SignalR, etc.) causes a native stack overflow that
+ // cannot be caught. Stopping the host is sufficient for test cleanup.
+ }
+
+ try { await base.DisposeAsync(); } catch { }
+
+ // Release pooled SQLite connections so the temp database file can be deleted.
+ if (_dbPath != null)
+ {
+ try
+ {
+ SqliteConnection.ClearAllPools();
+ if (File.Exists(_dbPath))
+ File.Delete(_dbPath);
+ }
+ catch
+ {
+ // Best-effort cleanup of the temp database file.
+ }
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ private static string FindServerProjectDirectory()
+ {
+ var dir = AppContext.BaseDirectory;
+ while (dir != null)
+ {
+ var candidate = Path.Combine(dir, "LANCommander.Server");
+ if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "LANCommander.Server.csproj")))
+ return candidate;
+
+ var slnx = Path.Combine(dir, "LANCommander.slnx");
+ if (File.Exists(slnx))
+ {
+ candidate = Path.Combine(dir, "LANCommander.Server");
+ if (Directory.Exists(candidate))
+ return candidate;
+ }
+
+ dir = Directory.GetParent(dir)?.FullName;
+ }
+
+ throw new DirectoryNotFoundException("Could not find LANCommander.Server project directory");
+ }
+}
+
+///
+/// Simple stub for IVersionProvider in UI tests.
+///
+internal class StubVersionProvider : IVersionProvider
+{
+ public SemVersion GetCurrentVersion() => SemVersion.Parse("1.0.0");
+ public ReleaseChannel GetReleaseChannel(SemVersion version) => ReleaseChannel.Stable;
+}
+
+///
+/// Simple stub for IGitHubService in UI tests.
+///
+internal class StubGitHubService : IGitHubService
+{
+ public Task GetLatestVersionAsync(ReleaseChannel releaseChannel)
+ => Task.FromResult(SemVersion.Parse("1.0.0"));
+
+ public Task GetReleaseAsync(SemVersion version)
+ => Task.FromResult(null);
+
+ public Task GetReleaseAsync(string tag)
+ => Task.FromResult(null);
+
+ public Task> GetReleasesAsync(int count)
+ => Task.FromResult>(Array.Empty());
+
+ public Task> GetNightlyArtifactsAsync(string versionOverride = null)
+ => Task.FromResult>(Array.Empty());
+
+ public Task> GetWorkflowArtifactsAsync(long runId)
+ => Task.FromResult>(Array.Empty());
+}
diff --git a/LANCommander.slnx b/LANCommander.slnx
index 654ad4f1..d95fb260 100644
--- a/LANCommander.slnx
+++ b/LANCommander.slnx
@@ -27,6 +27,7 @@
+