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