Merge pull request #396 from aaronpowell/spike/ui-automated-tests
Spike: Playwright UI automated tests for server application
60
.github/workflows/LANCommander.PR.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 761 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 8.4 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
LANCommander.Launcher/Views/ViewBackground.cs
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
4
LANCommander.Server.UI.Tests/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
using Xunit;
|
||||
|
||||
// Disable parallel test execution - these tests share a server port and data directory
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
104
LANCommander.Server.UI.Tests/Components/BUnitServerFixture.cs
Normal 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>
|
||||
{
|
||||
}
|
||||
81
LANCommander.Server.UI.Tests/Components/BUnitTestContext.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
125
LANCommander.Server.UI.Tests/ConfiguredServerFixture.cs
Normal 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<ConfiguredServerFixture>.
|
||||
/// </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);
|
||||
}
|
||||
}
|
||||
36
LANCommander.Server.UI.Tests/FreshServerFixture.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
101
LANCommander.Server.UI.Tests/Pages/AdminDashboardPage.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
94
LANCommander.Server.UI.Tests/Pages/FirstTimeSetupPage.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
LANCommander.Server.UI.Tests/Pages/LoginPage.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
37
LANCommander.Server.UI.Tests/PlaywrightFixture.cs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
69
LANCommander.Server.UI.Tests/ScreenshotHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
LANCommander.Server.UI.Tests/ServerCollection.cs
Normal 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>
|
||||
{
|
||||
}
|
||||
7
LANCommander.Server.UI.Tests/TestConstants.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace LANCommander.Server.UI.Tests;
|
||||
|
||||
public static class TestConstants
|
||||
{
|
||||
public const string AdminUserName = "admin";
|
||||
public const string AdminPassword = "Password1234!";
|
||||
}
|
||||
139
LANCommander.Server.UI.Tests/Tests/AdminNavigationTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
107
LANCommander.Server.UI.Tests/Tests/FirstTimeSetupTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
102
LANCommander.Server.UI.Tests/Tests/LoginTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
225
LANCommander.Server.UI.Tests/UITestApplicationFactory.cs
Normal 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>());
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||