Merge pull request #396 from aaronpowell/spike/ui-automated-tests

Spike: Playwright UI automated tests for server application
This commit is contained in:
Pat Hartl 2026-06-25 00:20:31 -05:00 committed by GitHub
commit e64439df66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1743 additions and 59 deletions

View file

@ -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

View file

@ -69,7 +69,9 @@
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.12.0" />
</ItemGroup>
<ItemGroup Label="Testing">
<PackageVersion Include="bunit" Version="1.40.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageVersion Include="Microsoft.TypeScript.MSBuild" Version="5.7.1" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Before After
Before After

View file

@ -1,8 +1,16 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:LANCommander.Launcher.Converters"
x:Class="LANCommander.Launcher.Tests.TestApp"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
<!-- Converters that views resolve from the application's resource scope.
The real App.axaml declares these globally; mirror only what the views
under test require so unstyled baselines stay otherwise unchanged. -->
<Application.Resources>
<converters:MultiplyConverter x:Key="MultiplyConverter" />
</Application.Resources>
</Application>

View file

@ -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<INavigationService, NavigationService>()
.BuildServiceProvider();
// ---------------------------------------------------------------------------

View file

@ -36,6 +36,10 @@
<PackageReference Include="ppy.SDL3-CS" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="LANCommander.Launcher.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.Launcher.Services\LANCommander.Launcher.Services.csproj" />
<ProjectReference Include="..\LANCommander.Launcher.Settings\LANCommander.Launcher.Settings.csproj" />

View file

@ -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();
}

View file

@ -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();
}
}

View file

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

View file

@ -0,0 +1,43 @@
using System;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace LANCommander.Launcher.Views;
/// <summary>
/// Picks a random full-screen background for the Login, Splash and ServerSelection
/// views. Visual-regression tests disable the random pick via <see cref="Enabled"/>
/// so the rendered output (and therefore the committed baseline) stays deterministic.
/// </summary>
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",
};
/// <summary>
/// When false, no random background is loaded. Set by visual-regression tests so
/// the rendered output is deterministic across runs.
/// </summary>
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 */ }
}
}

View file

@ -0,0 +1,4 @@
using Xunit;
// Disable parallel test execution - these tests share a server port and data directory
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View file

@ -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;
/// <summary>
/// Shared fixture for bUnit component tests. Reuses the proven <see cref="UITestApplicationFactory"/>
/// 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 <see cref="ConfiguredServerFixture"/> this does NOT start Playwright — bUnit renders
/// components synchronously in-process and needs only the DI container and seeded data.
/// </summary>
public class BUnitServerFixture : IAsyncLifetime
{
public UITestApplicationFactory Factory { get; private set; } = null!;
/// <summary>
/// ID of a game created via the service layer for edit component tests.
/// </summary>
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<RoleService>();
var userService = scope.ServiceProvider.GetRequiredService<UserService>();
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<StorageLocationService>();
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<GameService>();
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();
}
}
/// <summary>
/// xUnit collection definition that shares a single <see cref="BUnitServerFixture"/> across all
/// bUnit component test classes, keeping them isolated from the Playwright "Server" collection so
/// the static <see cref="DatabaseContext.Provider"/> is not contended.
/// </summary>
[CollectionDefinition("BUnit")]
public class BUnitCollection : ICollectionFixture<BUnitServerFixture>
{
}

View file

@ -0,0 +1,81 @@
using Bunit;
using Bunit.TestDoubles;
using LANCommander.Server.Services;
using Microsoft.Extensions.DependencyInjection;
namespace LANCommander.Server.UI.Tests.Components;
/// <summary>
/// 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 <see cref="BUnitServerFixture"/> via a fallback service provider. A fresh scope is
/// created per test so scoped services (and their DbContexts) behave like a single request.
/// </summary>
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.JsInterop.DomRect>(
"AntDesign.interop.domInfoHelper.getBoundingClientRect",
_ => true)
.SetResult(new AntDesign.JsInterop.DomRect());
// TextArea (AutoSize off) dereferences the text-area metrics on first render.
JSInterop
.Setup<AntDesign.Internal.TextAreaInfo>(
"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.JsInterop.Window>(
"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);
}
}

View file

@ -0,0 +1,67 @@
using Bunit;
using LANCommander.Server.UI.Pages.Games.Edit;
namespace LANCommander.Server.UI.Tests.Components;
/// <summary>
/// bUnit component tests for the game edit "General" page. These replace the flaky
/// Playwright equivalents in <c>GameEditTests</c> 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.
/// </summary>
[Collection("BUnit")]
public class GameEditComponentTests : BUnitTestContext
{
public GameEditComponentTests(BUnitServerFixture fixture) : base(fixture)
{
}
private IRenderedComponent<General> RenderGeneral()
=> RenderComponent<General>(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));
}
}

View file

@ -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;
/// <summary>
/// bUnit component tests for the metadata (Tags) management page. Replaces the flaky
/// Playwright <c>MetadataTests</c> 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.
/// </summary>
[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<TagService>();
foreach (var tag in await tagService.GetAsync())
await tagService.DeleteAsync(tag);
}
[Fact]
public async Task Tags_ShowsAddButton_AndEmptyState()
{
await ClearTagsAsync();
var cut = RenderComponent<TagsIndex>();
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<TagsIndex>();
// 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<TagService>();
Assert.Contains(await tagService.GetAsync(), t => t.Name == "Action");
}
}

View file

@ -0,0 +1,42 @@
using Bunit;
using ProfileIndex = LANCommander.Server.UI.Pages.Profile.Index;
namespace LANCommander.Server.UI.Tests.Components;
/// <summary>
/// bUnit component tests for the user profile page. Replaces the Playwright
/// <c>ProfileTests</c> 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.
/// </summary>
[Collection("BUnit")]
public class ProfileComponentTests : BUnitTestContext
{
public ProfileComponentTests(BUnitServerFixture fixture) : base(fixture)
{
}
[Fact]
public void Profile_ShowsCurrentUsername()
{
var cut = RenderComponent<ProfileIndex>();
// 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<ProfileIndex>();
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));
}
}

View file

@ -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;
/// <summary>
/// bUnit component tests for the role management page. Replaces the Playwright
/// <c>SettingsTests.SettingsRoles_ShowsRoleList</c> assertion that the seeded
/// Administrator role appears in the data table. Exercises the custom
/// <c>DataTable</c> which loads its rows asynchronously after first render via
/// the EF <c>IDbContextFactory</c>.
/// </summary>
[Collection("BUnit")]
public class RoleManagementComponentTests : BUnitTestContext
{
public RoleManagementComponentTests(BUnitServerFixture fixture) : base(fixture)
{
}
[Fact]
public void Roles_ShowsAddRoleButtonAndAdministratorRole()
{
var cut = RenderComponent<RolesIndex>();
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));
}
}

View file

@ -0,0 +1,29 @@
using Bunit;
using SettingsGeneral = LANCommander.Server.UI.Pages.Settings.General;
namespace LANCommander.Server.UI.Tests.Components;
/// <summary>
/// bUnit component tests for the admin Settings pages. Replaces the Playwright
/// <c>SettingsTests</c> assertions that verified each settings page renders its
/// expected form content. URL/routing assertions remain in the Playwright smoke layer.
/// </summary>
[Collection("BUnit")]
public class SettingsComponentTests : BUnitTestContext
{
public SettingsComponentTests(BUnitServerFixture fixture) : base(fixture)
{
}
[Fact]
public void SettingsGeneral_ShowsFormElements()
{
var cut = RenderComponent<SettingsGeneral>();
Assert.Contains("Port", cut.Markup);
Assert.Contains("Use SSL", cut.Markup);
Assert.Contains(
cut.FindAll("button"),
b => b.TextContent.Contains("Save", StringComparison.OrdinalIgnoreCase));
}
}

View file

@ -0,0 +1,29 @@
using Bunit;
using UsersIndex = LANCommander.Server.UI.Pages.Settings.Users.Index;
namespace LANCommander.Server.UI.Tests.Components;
/// <summary>
/// bUnit component tests for the user management page. Replaces the Playwright
/// <c>SettingsTests.SettingsUsers_ShowsUserList</c> assertion that the seeded
/// admin user appears in the data table.
/// </summary>
[Collection("BUnit")]
public class UserManagementComponentTests : BUnitTestContext
{
public UserManagementComponentTests(BUnitServerFixture fixture) : base(fixture)
{
}
[Fact]
public void Users_ShowsSeededAdminUser()
{
var cut = RenderComponent<UsersIndex>();
// 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));
}
}

View file

@ -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;
/// <summary>
/// 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&lt;ConfiguredServerFixture&gt;.
/// </summary>
public class ConfiguredServerFixture : IAsyncLifetime
{
public PlaywrightFixture Playwright { get; private set; } = null!;
public UITestApplicationFactory Factory { get; private set; } = null!;
/// <summary>
/// ID of a game created via the service layer for edit tests.
/// </summary>
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<RoleService>();
var userService = scope.ServiceProvider.GetRequiredService<UserService>();
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<StorageLocationService>();
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<GameService>();
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();
}
/// <summary>
/// Creates a new browser context and page, already logged in as admin.
/// </summary>
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);
}
/// <summary>
/// Creates a new browser context and page (not logged in).
/// </summary>
public async Task<(IBrowserContext Context, IPage Page)> CreateAnonymousPageAsync()
{
var context = await Playwright.NewContextAsync(Factory.BaseAddress);
var page = await context.NewPageAsync();
return (context, page);
}
}

View file

@ -0,0 +1,36 @@
using Microsoft.Playwright;
namespace LANCommander.Server.UI.Tests;
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="bunit" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Playwright" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.Server\LANCommander.Server.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,101 @@
using Microsoft.Playwright;
namespace LANCommander.Server.UI.Tests.Pages;
/// <summary>
/// Page object for the admin dashboard and navigation.
/// </summary>
public class AdminDashboardPage
{
private readonly IPage _page;
public AdminDashboardPage(IPage page)
{
_page = page;
}
public async Task<bool> IsDisplayedAsync()
{
return await _page.GetByText("Dashboard").First.IsVisibleAsync();
}
public async Task<string> 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 });
}
/// <summary>
/// Gets the visible menu items from the sidebar navigation.
/// </summary>
public async Task<IReadOnlyList<string>> 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<string>();
for (int i = 0; i < count; i++)
{
var text = await menuItems.Nth(i).TextContentAsync();
if (!string.IsNullOrWhiteSpace(text))
items.Add(text.Trim());
}
return items;
}
}

View file

@ -0,0 +1,94 @@
using Microsoft.Playwright;
namespace LANCommander.Server.UI.Tests.Pages;
/// <summary>
/// Page object for the First Time Setup wizard at /FirstTimeSetup.
/// </summary>
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<bool> 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();
}
/// <summary>
/// Completes the entire first-time setup wizard from start to finish.
/// </summary>
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 });
}
}

View file

@ -0,0 +1,46 @@
using Microsoft.Playwright;
namespace LANCommander.Server.UI.Tests.Pages;
/// <summary>
/// Page object for the login page at /Login.
/// </summary>
public class LoginPage
{
private readonly IPage _page;
public LoginPage(IPage page)
{
_page = page;
}
public async Task NavigateAsync()
{
await _page.GotoAsync("/Login");
}
public async Task<bool> 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<string?> GetErrorMessageAsync()
{
var errorLocator = _page.GetByText("Invalid login attempt.");
if (await errorLocator.IsVisibleAsync())
return await errorLocator.TextContentAsync();
return null;
}
public async Task<bool> HasRegisterLinkAsync()
{
return await _page.GetByRole(AriaRole.Link, new() { Name = "Register" }).IsVisibleAsync();
}
}

View file

@ -0,0 +1,37 @@
using Microsoft.Playwright;
namespace LANCommander.Server.UI.Tests;
/// <summary>
/// Shared Playwright fixture that manages browser lifetime across all tests in the collection.
/// Starts the server process and initializes Playwright once per test run.
/// </summary>
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<IBrowserContext> NewContextAsync(string baseUrl)
{
return await Browser.NewContextAsync(new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true,
BaseURL = baseUrl,
});
}
}

View file

@ -0,0 +1,69 @@
using System.Reflection;
using Microsoft.Playwright;
using Xunit.Abstractions;
namespace LANCommander.Server.UI.Tests;
/// <summary>
/// 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.)
/// </summary>
public static class ScreenshotHelper
{
private static readonly string ScreenshotDir = Path.Combine(
Environment.GetEnvironmentVariable("SCREENSHOT_DIR")
?? Path.Combine(AppContext.BaseDirectory, "Screenshots"),
string.Empty);
/// <summary>
/// Captures a screenshot of the current page state, named after the running test.
/// Call this from DisposeAsync — it extracts the test name from ITestOutputHelper.
/// </summary>
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
}
}
/// <summary>
/// Extracts the test display name from xUnit's ITestOutputHelper via reflection.
/// </summary>
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;
}
}
}

View file

@ -0,0 +1,20 @@
namespace LANCommander.Server.UI.Tests;
/// <summary>
/// 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.
/// </summary>
[CollectionDefinition("Server")]
public class ServerCollection : ICollectionFixture<ConfiguredServerFixture>
{
}
/// <summary>
/// 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.
/// </summary>
[CollectionDefinition("FirstTimeSetup")]
public class FirstTimeSetupCollection : ICollectionFixture<FreshServerFixture>
{
}

View file

@ -0,0 +1,7 @@
namespace LANCommander.Server.UI.Tests;
public static class TestConstants
{
public const string AdminUserName = "admin";
public const string AdminPassword = "Password1234!";
}

View file

@ -0,0 +1,139 @@
using LANCommander.Server.UI.Tests.Pages;
using Microsoft.Playwright;
using Xunit.Abstractions;
namespace LANCommander.Server.UI.Tests.Tests;
/// <summary>
/// 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.
/// </summary>
[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();
}
}

View file

@ -0,0 +1,107 @@
using LANCommander.Server.UI.Tests.Pages;
using Microsoft.Playwright;
using Xunit.Abstractions;
namespace LANCommander.Server.UI.Tests.Tests;
/// <summary>
/// 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.
/// </summary>
[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());
}
}

View file

@ -0,0 +1,102 @@
using LANCommander.Server.UI.Tests.Pages;
using Microsoft.Playwright;
using Xunit.Abstractions;
namespace LANCommander.Server.UI.Tests.Tests;
/// <summary>
/// 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.
/// </summary>
[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);
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
public class UITestApplicationFactory : WebApplicationFactory<Program>
{
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<DatabaseContext>));
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<DatabaseContext>(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<IVersionProvider, StubVersionProvider>();
// Mock IGitHubService
var gitHubServiceDescriptor = services.SingleOrDefault(
d => typeof(IGitHubService).IsAssignableFrom(d.ServiceType));
if (gitHubServiceDescriptor != null) services.Remove(gitHubServiceDescriptor);
services.AddSingleton<IGitHubService, StubGitHubService>();
// 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<IDbContextFactory<DatabaseContext>>();
using var context = contextFactory.CreateDbContext();
context.Database.EnsureCreated();
}
// Get the dynamically assigned port
var server = _realHost.Services.GetRequiredService<IServer>();
var addresses = server.Features.Get<IServerAddressesFeature>();
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");
}
}
/// <summary>
/// Simple stub for IVersionProvider in UI tests.
/// </summary>
internal class StubVersionProvider : IVersionProvider
{
public SemVersion GetCurrentVersion() => SemVersion.Parse("1.0.0");
public ReleaseChannel GetReleaseChannel(SemVersion version) => ReleaseChannel.Stable;
}
/// <summary>
/// Simple stub for IGitHubService in UI tests.
/// </summary>
internal class StubGitHubService : IGitHubService
{
public Task<SemVersion> GetLatestVersionAsync(ReleaseChannel releaseChannel)
=> Task.FromResult(SemVersion.Parse("1.0.0"));
public Task<Release?> GetReleaseAsync(SemVersion version)
=> Task.FromResult<Release?>(null);
public Task<Release?> GetReleaseAsync(string tag)
=> Task.FromResult<Release?>(null);
public Task<IEnumerable<Release>> GetReleasesAsync(int count)
=> Task.FromResult<IEnumerable<Release>>(Array.Empty<Release>());
public Task<IEnumerable<Artifact>> GetNightlyArtifactsAsync(string versionOverride = null)
=> Task.FromResult<IEnumerable<Artifact>>(Array.Empty<Artifact>());
public Task<IEnumerable<Artifact>> GetWorkflowArtifactsAsync(long runId)
=> Task.FromResult<IEnumerable<Artifact>>(Array.Empty<Artifact>());
}

View file

@ -27,6 +27,7 @@
<Project Path="LANCommander.Launcher.Services.Tests\LANCommander.Launcher.Services.Tests.csproj" />
<Project Path="LANCommander.SDK.Tests\LANCommander.SDK.Tests.csproj" />
<Project Path="LANCommander.Server.Tests\LANCommander.Server.Tests.csproj" Type="C#" />
<Project Path="LANCommander.Server.UI.Tests\LANCommander.Server.UI.Tests.csproj" />
</Folder>
<Folder Name="/Scripts/">
<File Path="Scripts\Clean.ps1" />