Migrate flaky Playwright admin UI tests to bUnit
Replace the SignalR-circuit-race-prone Playwright component tests for GameEdit, Settings, metadata, profile, roles and users with in-process bUnit component tests. Keep a thin Playwright smoke layer for the true E2E paths (login, admin navigation/routing, first-time setup, game import) that bUnit cannot cover. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af63a20bd2
commit
004a254c37
22 changed files with 469 additions and 1407 deletions
|
|
@ -69,6 +69,7 @@
|
|||
<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" />
|
||||
|
|
|
|||
104
LANCommander.Server.UI.Tests/Components/BUnitServerFixture.cs
Normal file
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
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="bunit" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
using Microsoft.Playwright;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Page object for the game edit page at /Games/{id}/General and related tabs.
|
||||
/// </summary>
|
||||
public class GameEditPage
|
||||
{
|
||||
private readonly IPage _page;
|
||||
private const int DefaultTimeout = 15000;
|
||||
|
||||
public GameEditPage(IPage page)
|
||||
{
|
||||
_page = page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates directly to a game's edit page by its ID.
|
||||
/// </summary>
|
||||
public async Task NavigateToGameByIdAsync(Guid gameId)
|
||||
{
|
||||
var uri = new Uri(_page.Url);
|
||||
var baseUrl = $"{uri.Scheme}://{uri.Authority}";
|
||||
await _page.GotoAsync($"{baseUrl}/Games/{gameId}/General");
|
||||
await WaitForFormLoadedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a game is imported and navigates to its edit page.
|
||||
/// If the game already exists, it opens the edit page directly.
|
||||
/// </summary>
|
||||
public async Task NavigateAsync(string gameTitle, string lcxFilePath)
|
||||
{
|
||||
var gamesPage = new GamesPage(_page);
|
||||
await gamesPage.NavigateAsync();
|
||||
|
||||
if (!await gamesPage.IsGameVisibleAsync(gameTitle, timeoutMs: 3000))
|
||||
{
|
||||
await gamesPage.ImportGameAsync(lcxFilePath);
|
||||
}
|
||||
|
||||
await gamesPage.OpenGameEditAsync(gameTitle);
|
||||
|
||||
// The edit page may land on /Games/{id} or /Games/{id}/General
|
||||
await WaitForFormLoadedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current game title from the form input.
|
||||
/// </summary>
|
||||
public async Task<string?> GetTitleAsync()
|
||||
{
|
||||
var input = GetTitleInput();
|
||||
await input.WaitForAsync(new() { Timeout = DefaultTimeout });
|
||||
return await input.InputValueAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the game title in the form input.
|
||||
/// </summary>
|
||||
public async Task SetTitleAsync(string title)
|
||||
{
|
||||
var input = GetTitleInput();
|
||||
await input.WaitForAsync(new() { Timeout = DefaultTimeout });
|
||||
await input.ClickAsync();
|
||||
await input.PressAsync("Control+a");
|
||||
await input.TypeAsync(title);
|
||||
await input.PressAsync("Tab");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clicks the Save button and waits for the success notification.
|
||||
/// </summary>
|
||||
public async Task SaveAsync()
|
||||
{
|
||||
await _page.GetByRole(AriaRole.Button, new() { Name = "Save", Exact = true }).ClickAsync();
|
||||
|
||||
// Wait for the success notification to appear
|
||||
try
|
||||
{
|
||||
await _page.WaitForSelectorAsync(".ant-notification", new() { Timeout = 10000 });
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Notification may not appear in all cases; continue
|
||||
}
|
||||
|
||||
await _page.WaitForTimeoutAsync(500);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to a specific tab by clicking the corresponding menu item in the game edit sidebar.
|
||||
/// </summary>
|
||||
public async Task NavigateToTabAsync(string tabName)
|
||||
{
|
||||
var menuItem = GetSiderMenu().GetByRole(AriaRole.Menuitem, new() { Name = tabName, Exact = true });
|
||||
await menuItem.ClickAsync();
|
||||
await _page.WaitForTimeoutAsync(1000);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a tab (menu item) with the given name is visible in the game edit sidebar.
|
||||
/// </summary>
|
||||
public async Task<bool> IsTabVisibleAsync(string tabName)
|
||||
{
|
||||
var menuItem = GetSiderMenu().GetByRole(AriaRole.Menuitem, new() { Name = tabName, Exact = true });
|
||||
|
||||
try
|
||||
{
|
||||
await menuItem.WaitForAsync(new() { Timeout = 5000 });
|
||||
return await menuItem.IsVisibleAsync();
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the Export button is visible on the page.
|
||||
/// </summary>
|
||||
public async Task<bool> IsExportButtonVisibleAsync()
|
||||
{
|
||||
return await _page.GetByRole(AriaRole.Button, new() { Name = "Export", Exact = true })
|
||||
.IsVisibleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the Save button is visible on the page.
|
||||
/// </summary>
|
||||
public async Task<bool> IsSaveButtonVisibleAsync()
|
||||
{
|
||||
return await _page.GetByRole(AriaRole.Button, new() { Name = "Save", Exact = true })
|
||||
.IsVisibleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current page URL.
|
||||
/// </summary>
|
||||
public string GetCurrentUrl() => _page.Url;
|
||||
|
||||
private ILocator GetTitleInput()
|
||||
{
|
||||
return _page.Locator(".ant-form-item")
|
||||
.Filter(new() { HasText = "Title" })
|
||||
.First
|
||||
.Locator("input")
|
||||
.First;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the sidebar menu locator scoped to the game edit panel layout.
|
||||
/// </summary>
|
||||
private ILocator GetSiderMenu()
|
||||
{
|
||||
return _page.Locator(".panel-layout .ant-layout-sider .ant-menu");
|
||||
}
|
||||
|
||||
private async Task WaitForFormLoadedAsync()
|
||||
{
|
||||
// Wait for the form to render by checking for the Title form item
|
||||
await _page.Locator(".ant-form-item").Filter(new() { HasText = "Title" }).First
|
||||
.WaitForAsync(new() { Timeout = DefaultTimeout });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
using Microsoft.Playwright;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable page object for metadata management pages (Tags, Genres, Platforms).
|
||||
/// All metadata pages share the same layout: a DataTable with Add/Edit/Delete actions
|
||||
/// and a modal dialog for creating/editing items.
|
||||
/// </summary>
|
||||
public class MetadataPage
|
||||
{
|
||||
private readonly IPage _page;
|
||||
private readonly string _metadataType;
|
||||
private readonly string _singularType;
|
||||
|
||||
/// <param name="page">Playwright page instance.</param>
|
||||
/// <param name="metadataType">Plural type name used in the URL, e.g. "Tags", "Genres", "Platforms".</param>
|
||||
public MetadataPage(IPage page, string metadataType)
|
||||
{
|
||||
_page = page;
|
||||
_metadataType = metadataType;
|
||||
_singularType = metadataType.TrimEnd('s');
|
||||
}
|
||||
|
||||
public async Task NavigateAsync()
|
||||
{
|
||||
await _page.GotoAsync($"/Metadata/{_metadataType}");
|
||||
await _page.WaitForSelectorAsync($"text={_metadataType}", new() { Timeout = 10000 });
|
||||
}
|
||||
|
||||
public async Task AddItemAsync(string name)
|
||||
{
|
||||
await _page.GetByRole(AriaRole.Button, new() { Name = $"Add {_singularType}" }).ClickAsync();
|
||||
|
||||
// Wait for the modal to appear
|
||||
await _page.WaitForSelectorAsync($"text=New {_singularType}", new() { Timeout = 5000 });
|
||||
|
||||
var nameInput = _page.Locator(".ant-modal").GetByRole(AriaRole.Textbox);
|
||||
await nameInput.FillAsync(name);
|
||||
|
||||
await _page.Locator(".ant-modal").GetByRole(AriaRole.Button, new() { Name = "OK" }).ClickAsync();
|
||||
|
||||
// Wait for modal to close and table to refresh
|
||||
await _page.WaitForSelectorAsync(".ant-modal", new() { State = WaitForSelectorState.Hidden, Timeout = 5000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locator for an item row's name cell within the table body.
|
||||
/// Use with web-first assertions, e.g. Expect(page.Item("Action")).ToBeVisibleAsync().
|
||||
/// </summary>
|
||||
public ILocator Item(string name)
|
||||
{
|
||||
// Look for the name text within the table body
|
||||
return _page.Locator(".ant-table-tbody").GetByText(name, new() { Exact = true });
|
||||
}
|
||||
|
||||
public async Task EditItemAsync(string oldName, string newName)
|
||||
{
|
||||
// Find the row containing the old name and click its Edit button
|
||||
var row = _page.Locator(".ant-table-tbody tr").Filter(new() { HasText = oldName });
|
||||
await row.GetByRole(AriaRole.Button, new() { Name = "Edit" }).ClickAsync();
|
||||
|
||||
// Wait for the edit modal to appear
|
||||
await _page.WaitForSelectorAsync($"text=Edit {_singularType}", new() { Timeout = 5000 });
|
||||
|
||||
var nameInput = _page.Locator(".ant-modal").GetByRole(AriaRole.Textbox);
|
||||
await nameInput.ClearAsync();
|
||||
await nameInput.FillAsync(newName);
|
||||
|
||||
await _page.Locator(".ant-modal").GetByRole(AriaRole.Button, new() { Name = "OK" }).ClickAsync();
|
||||
|
||||
// Wait for modal to close and table to refresh
|
||||
await _page.WaitForSelectorAsync(".ant-modal", new() { State = WaitForSelectorState.Hidden, Timeout = 5000 });
|
||||
}
|
||||
|
||||
public async Task DeleteItemAsync(string name)
|
||||
{
|
||||
// Find the row containing the name and click its delete (close icon) button
|
||||
var row = _page.Locator(".ant-table-tbody tr").Filter(new() { HasText = name });
|
||||
await row.Locator("button.ant-btn-dangerous").ClickAsync();
|
||||
|
||||
// Wait for popconfirm to appear and click OK to confirm deletion
|
||||
await _page.WaitForSelectorAsync(".ant-popover", new() { Timeout = 5000 });
|
||||
await _page.Locator(".ant-popover").GetByRole(AriaRole.Button, new() { Name = "OK" }).ClickAsync();
|
||||
|
||||
// Wait for popconfirm to close
|
||||
await _page.WaitForSelectorAsync(".ant-popover", new() { State = WaitForSelectorState.Hidden, Timeout = 5000 });
|
||||
}
|
||||
|
||||
public async Task<int> GetItemCountAsync()
|
||||
{
|
||||
var noData = _page.GetByText("No data");
|
||||
if (await noData.IsVisibleAsync())
|
||||
return 0;
|
||||
|
||||
return await _page.Locator(".ant-table-tbody tr").CountAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
using Microsoft.Playwright;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Page object for the user profile page at /Profile and the change password page at /Profile/ChangePassword.
|
||||
/// </summary>
|
||||
public class ProfilePage
|
||||
{
|
||||
private readonly IPage _page;
|
||||
|
||||
public ProfilePage(IPage page)
|
||||
{
|
||||
_page = page;
|
||||
}
|
||||
|
||||
public async Task NavigateAsync()
|
||||
{
|
||||
await _page.GotoAsync("/Profile");
|
||||
await _page.WaitForSelectorAsync("text=Profile", new() { Timeout = 10000 });
|
||||
}
|
||||
|
||||
public async Task<string?> GetUsernameAsync()
|
||||
{
|
||||
var input = _page.Locator(".ant-form-item")
|
||||
.Filter(new() { HasText = "Username" })
|
||||
.Locator("input");
|
||||
return await input.InputValueAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locator for a form field (text or password) with the given label.
|
||||
/// Use with web-first assertions, e.g. Expect(page.Field("Username")).ToBeVisibleAsync().
|
||||
/// </summary>
|
||||
public ILocator Field(string label)
|
||||
{
|
||||
return _page.Locator(".ant-form-item")
|
||||
.Filter(new() { HasText = label });
|
||||
}
|
||||
|
||||
public async Task SetAliasAsync(string alias)
|
||||
{
|
||||
var input = _page.Locator(".ant-form-item")
|
||||
.Filter(new() { HasText = "Alias" })
|
||||
.Locator("input");
|
||||
await input.ClickAsync();
|
||||
await input.PressAsync("Control+a");
|
||||
await input.TypeAsync(alias);
|
||||
// Blur to ensure change event fires
|
||||
await input.PressAsync("Tab");
|
||||
}
|
||||
|
||||
public async Task<string?> GetAliasAsync()
|
||||
{
|
||||
var input = _page.Locator(".ant-form-item")
|
||||
.Filter(new() { HasText = "Alias" })
|
||||
.Locator("input");
|
||||
return await input.InputValueAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clicks the Save button. Note: saving the profile triggers a redirect to /Logout?force=true.
|
||||
/// </summary>
|
||||
public async Task SaveAsync()
|
||||
{
|
||||
await _page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();
|
||||
}
|
||||
|
||||
public async Task NavigateToChangePasswordAsync()
|
||||
{
|
||||
await _page.GotoAsync("/Profile/ChangePassword");
|
||||
await _page.WaitForSelectorAsync("text=Change Password", new() { Timeout = 10000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills the change password form and submits it.
|
||||
/// </summary>
|
||||
public async Task ChangePasswordAsync(string currentPassword, string newPassword)
|
||||
{
|
||||
// Fill Current Password if visible
|
||||
var currentPasswordField = _page.Locator(".ant-form-item")
|
||||
.Filter(new() { HasText = "Current Password" })
|
||||
.Locator("input");
|
||||
|
||||
if (await currentPasswordField.IsVisibleAsync())
|
||||
await currentPasswordField.FillAsync(currentPassword);
|
||||
|
||||
// Fill New Password
|
||||
await _page.Locator(".ant-form-item")
|
||||
.Filter(new() { HasText = "New Password" })
|
||||
.Locator("input")
|
||||
.FillAsync(newPassword);
|
||||
|
||||
// Fill Confirm Password
|
||||
await _page.Locator(".ant-form-item")
|
||||
.Filter(new() { HasText = "Confirm Password" })
|
||||
.Locator("input")
|
||||
.FillAsync(newPassword);
|
||||
|
||||
// Click Change button
|
||||
await _page.GetByRole(AriaRole.Button, new() { Name = "Change" }).ClickAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
using Microsoft.Playwright;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Page object for the Roles management page at /Settings/Roles.
|
||||
/// </summary>
|
||||
public class RolesPage
|
||||
{
|
||||
private readonly IPage _page;
|
||||
|
||||
public RolesPage(IPage page)
|
||||
{
|
||||
_page = page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigate to Settings > Roles via the sidebar menu.
|
||||
/// </summary>
|
||||
public async Task NavigateAsync()
|
||||
{
|
||||
await _page.GetByRole(AriaRole.Button, new() { Name = "Settings" }).ClickAsync();
|
||||
await _page.GetByRole(AriaRole.Link, new() { Name = "Roles", Exact = true }).ClickAsync();
|
||||
await _page.WaitForURLAsync("**/Settings/Roles", new() { Timeout = 10000 });
|
||||
await _page.WaitForSelectorAsync("text=Add Role", new() { Timeout = 10000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Click "Add Role", fill in the name, and confirm the modal.
|
||||
/// </summary>
|
||||
public async Task AddRoleAsync(string name)
|
||||
{
|
||||
await _page.GetByRole(AriaRole.Button, new() { Name = "Add Role" }).ClickAsync();
|
||||
|
||||
// Wait for the modal to appear
|
||||
await _page.WaitForSelectorAsync(".ant-modal", new() { State = WaitForSelectorState.Visible, Timeout = 10000 });
|
||||
|
||||
// Fill the Name input inside the modal
|
||||
var modal = _page.Locator(".ant-modal");
|
||||
await modal.GetByRole(AriaRole.Textbox).FillAsync(name);
|
||||
|
||||
// Click the OK button in the modal footer
|
||||
await modal.Locator(".ant-modal-footer").GetByRole(AriaRole.Button, new() { Name = "OK" }).ClickAsync();
|
||||
|
||||
// Wait for modal to close
|
||||
await _page.WaitForSelectorAsync(".ant-modal", new() { State = WaitForSelectorState.Hidden, Timeout = 10000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locator for a role row with the given name.
|
||||
/// Use with web-first assertions, e.g. Expect(page.Role("Administrator")).ToBeVisibleAsync().
|
||||
/// </summary>
|
||||
public ILocator Role(string name)
|
||||
{
|
||||
return _page.Locator("table tbody tr").Filter(new() { HasText = name });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a role by clicking its delete button and confirming the popconfirm.
|
||||
/// </summary>
|
||||
public async Task DeleteRoleAsync(string name)
|
||||
{
|
||||
var row = _page.Locator("table tbody tr").Filter(new() { HasText = name });
|
||||
|
||||
// Click the close/delete button (the danger text button with close icon)
|
||||
await row.Locator("button.ant-btn-dangerous").ClickAsync();
|
||||
|
||||
// Wait for the popconfirm popover to appear and click the OK button
|
||||
var okButton = _page.Locator(".ant-popover .ant-btn-primary");
|
||||
await okButton.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 10000 });
|
||||
await okButton.ClickAsync();
|
||||
|
||||
// Wait for the row to be removed from the table
|
||||
await row.WaitForAsync(new() { State = WaitForSelectorState.Hidden, Timeout = 10000 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the delete button for a given role is disabled.
|
||||
/// </summary>
|
||||
public async Task<bool> IsDeleteDisabledAsync(string name)
|
||||
{
|
||||
var row = _page.Locator("table tbody tr").Filter(new() { HasText = name });
|
||||
|
||||
// The close button in the row (both the real delete and disabled version use the same icon)
|
||||
var deleteButton = row.Locator("button").Filter(new() { Has = _page.Locator("[aria-label='close']") });
|
||||
|
||||
if (await deleteButton.CountAsync() == 0)
|
||||
return true; // No delete button means it's effectively non-deletable
|
||||
|
||||
return await deleteButton.IsDisabledAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
using Microsoft.Playwright;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Page object for navigating to and interacting with Settings sub-pages.
|
||||
/// </summary>
|
||||
public class SettingsPage
|
||||
{
|
||||
private readonly IPage _page;
|
||||
private const int DefaultTimeout = 15000;
|
||||
|
||||
public SettingsPage(IPage page)
|
||||
{
|
||||
_page = page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands the Settings submenu in the sidebar if it's not already open.
|
||||
/// </summary>
|
||||
private async Task ExpandSettingsMenuAsync()
|
||||
{
|
||||
await _page.GetByRole(AriaRole.Button, new() { Name = "Settings" }).ClickAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to a settings sub-page by clicking the Settings menu button, then the sub-item link.
|
||||
/// Waits for the URL to match and the page header to render.
|
||||
/// </summary>
|
||||
private async Task NavigateToSettingsSubPageAsync(string linkName, string urlSegment, string? waitForText = null)
|
||||
{
|
||||
await ExpandSettingsMenuAsync();
|
||||
await _page.GetByRole(AriaRole.Link, new() { Name = linkName, Exact = true }).ClickAsync();
|
||||
await _page.WaitForURLAsync($"**/Settings/{urlSegment}", new() { Timeout = DefaultTimeout });
|
||||
|
||||
if (waitForText != null)
|
||||
await _page.WaitForSelectorAsync($"text={waitForText}", new() { Timeout = DefaultTimeout });
|
||||
}
|
||||
|
||||
public async Task NavigateToGeneralAsync()
|
||||
{
|
||||
await NavigateToSettingsSubPageAsync("General", "General", "Use SSL");
|
||||
}
|
||||
|
||||
public async Task NavigateToUsersAsync()
|
||||
{
|
||||
await NavigateToSettingsSubPageAsync("Users", "Users", "Username");
|
||||
}
|
||||
|
||||
public async Task NavigateToRolesAsync()
|
||||
{
|
||||
await NavigateToSettingsSubPageAsync("Roles", "Roles", "Add Role");
|
||||
}
|
||||
|
||||
public async Task NavigateToAuthenticationAsync()
|
||||
{
|
||||
await NavigateToSettingsSubPageAsync("Authentication", "Authentication", "Authentication");
|
||||
}
|
||||
|
||||
public async Task NavigateToArchivesAsync()
|
||||
{
|
||||
await NavigateToSettingsSubPageAsync("Archives", "Archives", "Archives");
|
||||
}
|
||||
|
||||
public async Task NavigateToMediaAsync()
|
||||
{
|
||||
await NavigateToSettingsSubPageAsync("Media", "Media", "Media");
|
||||
}
|
||||
|
||||
public async Task NavigateToUpdatesAsync()
|
||||
{
|
||||
await NavigateToSettingsSubPageAsync("Updates", "Updates", "Updates");
|
||||
}
|
||||
|
||||
public async Task NavigateToAppearanceAsync()
|
||||
{
|
||||
await NavigateToSettingsSubPageAsync("Appearance", "Appearance", "Appearance");
|
||||
}
|
||||
|
||||
public async Task NavigateToBeaconAsync()
|
||||
{
|
||||
await NavigateToSettingsSubPageAsync("Beacon", "Beacon", "Beacon");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
using Microsoft.Playwright;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// Page object for the Settings > Users page at /Settings/Users.
|
||||
/// </summary>
|
||||
public class UsersPage
|
||||
{
|
||||
private readonly IPage _page;
|
||||
|
||||
public UsersPage(IPage page)
|
||||
{
|
||||
_page = page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the Users page via the Settings sidebar menu and waits for the table to load.
|
||||
/// </summary>
|
||||
public async Task NavigateAsync()
|
||||
{
|
||||
await _page.GetByRole(AriaRole.Button, new() { Name = "Settings" }).ClickAsync();
|
||||
await _page.GetByRole(AriaRole.Link, new() { Name = "Users", Exact = true }).ClickAsync();
|
||||
await _page.WaitForURLAsync("**/Settings/Users", new() { Timeout = 10000 });
|
||||
// Wait for table data rows to render (Blazor SSR + async data load)
|
||||
await WaitForTableDataAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of data rows in the users table.
|
||||
/// </summary>
|
||||
public async Task<int> GetUserCountAsync()
|
||||
{
|
||||
return await _page.Locator("tr.ant-table-row").CountAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for users by typing into the DataTable search input and waiting for results.
|
||||
/// </summary>
|
||||
public async Task SearchUsersAsync(string query)
|
||||
{
|
||||
var searchInput = _page.GetByPlaceholder("Search");
|
||||
await searchInput.FillAsync(query);
|
||||
// Press Enter to ensure the search triggers via Blazor's event pipeline
|
||||
await searchInput.PressAsync("Enter");
|
||||
// Wait for the server-side search to complete and table to re-render
|
||||
await _page.WaitForLoadStateAsync(LoadState.NetworkIdle, new() { Timeout = 10000 });
|
||||
await _page.WaitForTimeoutAsync(1000);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locator for a table row containing the given username.
|
||||
/// Use with web-first assertions, e.g. Expect(page.User("admin")).ToBeVisibleAsync().
|
||||
/// </summary>
|
||||
public ILocator User(string username)
|
||||
{
|
||||
return _page.Locator("tr.ant-table-row", new() { HasTextString = username });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the text content of the Roles column for a given user.
|
||||
/// </summary>
|
||||
public async Task<string> GetUserRolesTextAsync(string username)
|
||||
{
|
||||
var row = _page.Locator("tr.ant-table-row", new() { HasTextString = username });
|
||||
// Roles are rendered as <span class="ant-tag"> inside the row
|
||||
var tags = row.Locator(".ant-tag");
|
||||
var count = await tags.CountAsync();
|
||||
var roles = new List<string>();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var text = await tags.Nth(i).TextContentAsync();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
roles.Add(text.Trim());
|
||||
}
|
||||
return string.Join(", ", roles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a user by clicking the delete button on their row and confirming the popconfirm.
|
||||
/// </summary>
|
||||
public async Task DeleteUserAsync(string username)
|
||||
{
|
||||
var row = _page.Locator("tr.ant-table-row", new() { HasTextString = username });
|
||||
// Click the danger button (delete icon) in the row - AntDesign renders it as ant-btn-dangerous
|
||||
await row.Locator("button.ant-btn-dangerous").ClickAsync();
|
||||
// Wait for the Popconfirm overlay to appear and click OK
|
||||
var okButton = _page.GetByRole(AriaRole.Button, new() { Name = "OK" });
|
||||
await okButton.WaitForAsync(new() { Timeout = 5000 });
|
||||
await okButton.ClickAsync();
|
||||
// Wait for the deletion round-trip and table re-render
|
||||
await _page.WaitForLoadStateAsync(LoadState.NetworkIdle, new() { Timeout = 10000 });
|
||||
await _page.WaitForTimeoutAsync(1000);
|
||||
}
|
||||
|
||||
private async Task WaitForTableDataAsync()
|
||||
{
|
||||
await _page.Locator("tr.ant-table-row").First.WaitForAsync(new() { Timeout = 15000 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
using LANCommander.Server.UI.Tests.Pages;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the game edit page, covering tab navigation, form fields, and persistence.
|
||||
/// Uses a game created via the service layer (seeded in ConfiguredServerFixture).
|
||||
/// </summary>
|
||||
[Collection("Server")]
|
||||
public class GameEditTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConfiguredServerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private IBrowserContext _context = null!;
|
||||
private IPage _page = null!;
|
||||
|
||||
public GameEditTests(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();
|
||||
}
|
||||
|
||||
private async Task<GameEditPage> NavigateToGameEditAsync()
|
||||
{
|
||||
var editPage = new GameEditPage(_page);
|
||||
await editPage.NavigateToGameByIdAsync(_fixture.TestGameId);
|
||||
return editPage;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GameEdit_ShowsGeneralTab()
|
||||
{
|
||||
var editPage = await NavigateToGameEditAsync();
|
||||
|
||||
var title = await editPage.GetTitleAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(title), "Title input should have a value");
|
||||
Assert.Contains("/Games/", editPage.GetCurrentUrl());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GameEdit_CanModifyTitle()
|
||||
{
|
||||
var editPage = await NavigateToGameEditAsync();
|
||||
const string modifiedTitle = "Test Game Modified";
|
||||
|
||||
await editPage.SetTitleAsync(modifiedTitle);
|
||||
await editPage.SaveAsync();
|
||||
|
||||
// Reload the page to verify persistence
|
||||
await _page.ReloadAsync();
|
||||
await _page.WaitForSelectorAsync(".ant-form-item", new() { Timeout = 15000 });
|
||||
|
||||
var editPageAfterReload = new GameEditPage(_page);
|
||||
var titleAfterReload = await editPageAfterReload.GetTitleAsync();
|
||||
Assert.Equal(modifiedTitle, titleAfterReload);
|
||||
|
||||
// Restore original title for other tests
|
||||
await editPageAfterReload.SetTitleAsync(ConfiguredServerFixture.TestGameTitle);
|
||||
await editPageAfterReload.SaveAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GameEdit_ShowsAllExpectedTabs()
|
||||
{
|
||||
var editPage = await NavigateToGameEditAsync();
|
||||
|
||||
var expectedTabs = new[] { "General", "Archives", "Media", "Scripts", "Actions", "Keys" };
|
||||
|
||||
foreach (var tab in expectedTabs)
|
||||
{
|
||||
Assert.True(await editPage.IsTabVisibleAsync(tab), $"Tab '{tab}' should be visible");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GameEdit_CanNavigateToMediaTab()
|
||||
{
|
||||
var editPage = await NavigateToGameEditAsync();
|
||||
|
||||
await editPage.NavigateToTabAsync("Media");
|
||||
|
||||
Assert.Contains("/Media", editPage.GetCurrentUrl());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GameEdit_CanNavigateToScriptsTab()
|
||||
{
|
||||
var editPage = await NavigateToGameEditAsync();
|
||||
|
||||
await editPage.NavigateToTabAsync("Scripts");
|
||||
|
||||
Assert.Contains("/Scripts", editPage.GetCurrentUrl());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GameEdit_HasExportButton()
|
||||
{
|
||||
var editPage = await NavigateToGameEditAsync();
|
||||
|
||||
Assert.True(await editPage.IsExportButtonVisibleAsync(), "Export button should be visible");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GameEdit_HasSaveButton()
|
||||
{
|
||||
var editPage = await NavigateToGameEditAsync();
|
||||
|
||||
Assert.True(await editPage.IsSaveButtonVisibleAsync(), "Save button should be visible");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
using LANCommander.Server.Services;
|
||||
using LANCommander.Server.UI.Tests.Pages;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for metadata management pages (Tags, Genres, Platforms).
|
||||
/// Verifies CRUD operations through the admin UI using a shared page object.
|
||||
/// </summary>
|
||||
[Collection("Server")]
|
||||
public class MetadataTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConfiguredServerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private IBrowserContext _context = null!;
|
||||
private IPage _page = null!;
|
||||
|
||||
public MetadataTests(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();
|
||||
}
|
||||
|
||||
// --- Tags ---
|
||||
|
||||
[Fact]
|
||||
public async Task TagsPage_ShowsEmptyState()
|
||||
{
|
||||
// Clear any tags left by other tests so the empty state is visible
|
||||
using (var scope = _fixture.Factory.RealServices.CreateScope())
|
||||
{
|
||||
var tagService = scope.ServiceProvider.GetRequiredService<TagService>();
|
||||
var existing = await tagService.GetAsync();
|
||||
foreach (var tag in existing)
|
||||
await tagService.DeleteAsync(tag);
|
||||
}
|
||||
|
||||
var tagsPage = new MetadataPage(_page, "Tags");
|
||||
await tagsPage.NavigateAsync();
|
||||
|
||||
Assert.Equal(0, await tagsPage.GetItemCountAsync());
|
||||
await Assertions.Expect(_page.GetByText("No data")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TagsPage_CanAddTag()
|
||||
{
|
||||
var tagsPage = new MetadataPage(_page, "Tags");
|
||||
await tagsPage.NavigateAsync();
|
||||
|
||||
await tagsPage.AddItemAsync("Action");
|
||||
|
||||
await Assertions.Expect(tagsPage.Item("Action")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TagsPage_CanEditTag()
|
||||
{
|
||||
var tagsPage = new MetadataPage(_page, "Tags");
|
||||
await tagsPage.NavigateAsync();
|
||||
|
||||
await tagsPage.AddItemAsync("Puzzle");
|
||||
await Assertions.Expect(tagsPage.Item("Puzzle")).ToBeVisibleAsync();
|
||||
|
||||
await tagsPage.EditItemAsync("Puzzle", "Puzzle Games");
|
||||
await Assertions.Expect(tagsPage.Item("Puzzle Games")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TagsPage_CanDeleteTag()
|
||||
{
|
||||
var tagsPage = new MetadataPage(_page, "Tags");
|
||||
await tagsPage.NavigateAsync();
|
||||
|
||||
await tagsPage.AddItemAsync("Temporary");
|
||||
await Assertions.Expect(tagsPage.Item("Temporary")).ToBeVisibleAsync();
|
||||
|
||||
await tagsPage.DeleteItemAsync("Temporary");
|
||||
await Assertions.Expect(tagsPage.Item("Temporary")).ToBeHiddenAsync();
|
||||
}
|
||||
|
||||
// --- Genres ---
|
||||
|
||||
[Fact]
|
||||
public async Task GenresPage_CanAddGenre()
|
||||
{
|
||||
var genresPage = new MetadataPage(_page, "Genres");
|
||||
await genresPage.NavigateAsync();
|
||||
|
||||
await genresPage.AddItemAsync("RPG");
|
||||
|
||||
await Assertions.Expect(genresPage.Item("RPG")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenresPage_CanDeleteGenre()
|
||||
{
|
||||
var genresPage = new MetadataPage(_page, "Genres");
|
||||
await genresPage.NavigateAsync();
|
||||
|
||||
await genresPage.AddItemAsync("Strategy");
|
||||
await Assertions.Expect(genresPage.Item("Strategy")).ToBeVisibleAsync();
|
||||
|
||||
await genresPage.DeleteItemAsync("Strategy");
|
||||
await Assertions.Expect(genresPage.Item("Strategy")).ToBeHiddenAsync();
|
||||
}
|
||||
|
||||
// --- Platforms ---
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformsPage_CanAddPlatform()
|
||||
{
|
||||
var platformsPage = new MetadataPage(_page, "Platforms");
|
||||
await platformsPage.NavigateAsync();
|
||||
|
||||
await platformsPage.AddItemAsync("Windows");
|
||||
|
||||
await Assertions.Expect(platformsPage.Item("Windows")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformsPage_CanDeletePlatform()
|
||||
{
|
||||
var platformsPage = new MetadataPage(_page, "Platforms");
|
||||
await platformsPage.NavigateAsync();
|
||||
|
||||
await platformsPage.AddItemAsync("Linux");
|
||||
await Assertions.Expect(platformsPage.Item("Linux")).ToBeVisibleAsync();
|
||||
|
||||
await platformsPage.DeleteItemAsync("Linux");
|
||||
await Assertions.Expect(platformsPage.Item("Linux")).ToBeHiddenAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
using LANCommander.Server.UI.Tests.Pages;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the user profile and change password pages.
|
||||
/// Uses the shared "Server" collection fixture so the server instance is shared across the collection.
|
||||
/// </summary>
|
||||
[Collection("Server")]
|
||||
public class ProfileTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConfiguredServerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private IBrowserContext _context = null!;
|
||||
private IPage _page = null!;
|
||||
|
||||
public ProfileTests(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 ProfilePage_ShowsCurrentUsername()
|
||||
{
|
||||
var profilePage = new ProfilePage(_page);
|
||||
await profilePage.NavigateAsync();
|
||||
|
||||
var username = await profilePage.GetUsernameAsync();
|
||||
|
||||
Assert.Equal(TestConstants.AdminUserName, username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProfilePage_ShowsFormElements()
|
||||
{
|
||||
var profilePage = new ProfilePage(_page);
|
||||
await profilePage.NavigateAsync();
|
||||
|
||||
await Assertions.Expect(profilePage.Field("Username")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(profilePage.Field("Alias")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(profilePage.Field("Email Address")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(_page.GetByRole(AriaRole.Button, new() { Name = "Save" })).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProfilePage_CanUpdateAlias()
|
||||
{
|
||||
var profilePage = new ProfilePage(_page);
|
||||
await profilePage.NavigateAsync();
|
||||
|
||||
await profilePage.SetAliasAsync("Test Admin");
|
||||
await profilePage.SaveAsync();
|
||||
|
||||
// Saving triggers a logout redirect; wait for the login page
|
||||
await _page.WaitForURLAsync("**/Login**", new() { Timeout = 15000 });
|
||||
|
||||
// Re-login and navigate back to profile to verify persistence
|
||||
var loginPage = new LoginPage(_page);
|
||||
await loginPage.LoginAsync(TestConstants.AdminUserName, TestConstants.AdminPassword);
|
||||
await _page.WaitForSelectorAsync("text=Dashboard", new() { Timeout = 15000 });
|
||||
|
||||
await profilePage.NavigateAsync();
|
||||
|
||||
var alias = await profilePage.GetAliasAsync();
|
||||
Assert.Equal("Test Admin", alias);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_PageShowsFormElements()
|
||||
{
|
||||
var profilePage = new ProfilePage(_page);
|
||||
await profilePage.NavigateToChangePasswordAsync();
|
||||
|
||||
await Assertions.Expect(profilePage.Field("Current Password")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(profilePage.Field("New Password")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(profilePage.Field("Confirm Password")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(_page.GetByRole(AriaRole.Button, new() { Name = "Change" })).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_CanChangePassword()
|
||||
{
|
||||
const string newPassword = "NewPassword123!";
|
||||
|
||||
// Step 1: Change the password
|
||||
var profilePage = new ProfilePage(_page);
|
||||
await profilePage.NavigateToChangePasswordAsync();
|
||||
await profilePage.ChangePasswordAsync(TestConstants.AdminPassword, newPassword);
|
||||
|
||||
// Wait for success message
|
||||
await _page.WaitForSelectorAsync("text=Password changed!", new() { Timeout = 10000 });
|
||||
|
||||
// Step 2: Verify login works with the new password in a fresh browser context
|
||||
var (anonContext, anonPage) = await _fixture.CreateAnonymousPageAsync();
|
||||
try
|
||||
{
|
||||
var loginPage = new LoginPage(anonPage);
|
||||
await loginPage.NavigateAsync();
|
||||
await loginPage.LoginAsync(TestConstants.AdminUserName, newPassword);
|
||||
await anonPage.WaitForSelectorAsync("text=Dashboard", new() { Timeout = 15000 });
|
||||
|
||||
Assert.Contains("/", anonPage.Url);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await anonPage.CloseAsync();
|
||||
await anonContext.DisposeAsync();
|
||||
}
|
||||
|
||||
// Step 3: Change the password back to the original so other tests aren't affected
|
||||
await profilePage.NavigateToChangePasswordAsync();
|
||||
await profilePage.ChangePasswordAsync(newPassword, TestConstants.AdminPassword);
|
||||
await _page.WaitForSelectorAsync("text=Password changed!", new() { Timeout = 10000 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
using LANCommander.Server.UI.Tests.Pages;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Role Management UI at Settings > Roles.
|
||||
/// Verifies adding, viewing, and deleting roles, and that the
|
||||
/// Administrator role cannot be deleted.
|
||||
/// </summary>
|
||||
[Collection("Server")]
|
||||
public class RoleManagementTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConfiguredServerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private IBrowserContext _context = null!;
|
||||
private IPage _page = null!;
|
||||
|
||||
public RoleManagementTests(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 RolesPage_ShowsAdministratorRole()
|
||||
{
|
||||
var rolesPage = new RolesPage(_page);
|
||||
await rolesPage.NavigateAsync();
|
||||
|
||||
await Assertions.Expect(rolesPage.Role("Administrator")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolesPage_CanAddNewRole()
|
||||
{
|
||||
var rolesPage = new RolesPage(_page);
|
||||
await rolesPage.NavigateAsync();
|
||||
|
||||
await rolesPage.AddRoleAsync("TestRole");
|
||||
|
||||
await Assertions.Expect(rolesPage.Role("TestRole")).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolesPage_CanDeleteRole()
|
||||
{
|
||||
var rolesPage = new RolesPage(_page);
|
||||
await rolesPage.NavigateAsync();
|
||||
|
||||
// Add a role to delete
|
||||
await rolesPage.AddRoleAsync("RoleToDelete");
|
||||
await Assertions.Expect(rolesPage.Role("RoleToDelete")).ToBeVisibleAsync();
|
||||
|
||||
// Delete the role
|
||||
await rolesPage.DeleteRoleAsync("RoleToDelete");
|
||||
|
||||
await Assertions.Expect(rolesPage.Role("RoleToDelete")).ToBeHiddenAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RolesPage_AdministratorCannotBeDeleted()
|
||||
{
|
||||
var rolesPage = new RolesPage(_page);
|
||||
await rolesPage.NavigateAsync();
|
||||
|
||||
Assert.True(await rolesPage.IsDeleteDisabledAsync("Administrator"),
|
||||
"The delete button for Administrator role should be disabled");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
using LANCommander.Server.UI.Tests.Pages;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Settings pages of the admin application.
|
||||
/// Verifies that each settings sub-page is accessible and renders its expected content.
|
||||
/// </summary>
|
||||
[Collection("Server")]
|
||||
public class SettingsTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConfiguredServerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private IBrowserContext _context = null!;
|
||||
private IPage _page = null!;
|
||||
|
||||
public SettingsTests(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 SettingsGeneral_ShowsFormElements()
|
||||
{
|
||||
var settings = new SettingsPage(_page);
|
||||
await settings.NavigateToGeneralAsync();
|
||||
|
||||
Assert.Contains("/Settings/General", _page.Url);
|
||||
await Assertions.Expect(_page.GetByText("Use SSL")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(_page.GetByText("Port").First).ToBeVisibleAsync();
|
||||
await Assertions.Expect(_page.GetByRole(AriaRole.Button, new() { Name = "Save" })).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsUsers_ShowsUserList()
|
||||
{
|
||||
var settings = new SettingsPage(_page);
|
||||
await settings.NavigateToUsersAsync();
|
||||
|
||||
Assert.Contains("/Settings/Users", _page.Url);
|
||||
// Wait for the data table to render with user data
|
||||
await _page.Locator("table").First.WaitForAsync(new() { Timeout = 15000 });
|
||||
// The admin user created during fixture setup should appear in the table
|
||||
var adminCell = _page.Locator("table").GetByText(TestConstants.AdminUserName).First;
|
||||
await adminCell.WaitForAsync(new() { Timeout = 15000 });
|
||||
await Assertions.Expect(adminCell).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsRoles_ShowsRoleList()
|
||||
{
|
||||
var settings = new SettingsPage(_page);
|
||||
await settings.NavigateToRolesAsync();
|
||||
|
||||
Assert.Contains("/Settings/Roles", _page.Url);
|
||||
await Assertions.Expect(_page.GetByRole(AriaRole.Button, new() { Name = "Add Role" })).ToBeVisibleAsync();
|
||||
// Wait for the data table to render then check for the Administrator role
|
||||
await _page.Locator("table").First.WaitForAsync(new() { Timeout = 15000 });
|
||||
var adminRole = _page.Locator("table").GetByText("Administrator");
|
||||
await adminRole.WaitForAsync(new() { Timeout = 15000 });
|
||||
await Assertions.Expect(adminRole).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsAuthentication_IsAccessible()
|
||||
{
|
||||
var settings = new SettingsPage(_page);
|
||||
await settings.NavigateToAuthenticationAsync();
|
||||
|
||||
Assert.Contains("/Settings/Authentication", _page.Url);
|
||||
await Assertions.Expect(_page.GetByText("Authentication").First).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsArchives_IsAccessible()
|
||||
{
|
||||
var settings = new SettingsPage(_page);
|
||||
await settings.NavigateToArchivesAsync();
|
||||
|
||||
Assert.Contains("/Settings/Archives", _page.Url);
|
||||
await Assertions.Expect(_page.GetByText("Archives").First).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsMedia_IsAccessible()
|
||||
{
|
||||
var settings = new SettingsPage(_page);
|
||||
await settings.NavigateToMediaAsync();
|
||||
|
||||
Assert.Contains("/Settings/Media", _page.Url);
|
||||
await Assertions.Expect(_page.GetByText("Media").First).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsLogs_IsAccessible()
|
||||
{
|
||||
// Logs page does not exist; testing Beacon settings instead
|
||||
var settings = new SettingsPage(_page);
|
||||
await settings.NavigateToBeaconAsync();
|
||||
|
||||
Assert.Contains("/Settings/Beacon", _page.Url);
|
||||
await Assertions.Expect(_page.GetByText("Beacon").First).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsUpdates_IsAccessible()
|
||||
{
|
||||
var settings = new SettingsPage(_page);
|
||||
await settings.NavigateToUpdatesAsync();
|
||||
|
||||
Assert.Contains("/Settings/Updates", _page.Url);
|
||||
await Assertions.Expect(_page.GetByText("Updates").First).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsAppearance_IsAccessible()
|
||||
{
|
||||
var settings = new SettingsPage(_page);
|
||||
await settings.NavigateToAppearanceAsync();
|
||||
|
||||
Assert.Contains("/Settings/Appearance", _page.Url);
|
||||
await Assertions.Expect(_page.GetByText("Appearance").First).ToBeVisibleAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
using LANCommander.Server.UI.Tests.Pages;
|
||||
using Microsoft.Playwright;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace LANCommander.Server.UI.Tests.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the Settings > Users page and user registration flow.
|
||||
/// </summary>
|
||||
[Collection("Server")]
|
||||
public class UserManagementTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ConfiguredServerFixture _fixture;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private IBrowserContext _context = null!;
|
||||
private IPage _page = null!;
|
||||
|
||||
public UserManagementTests(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 UsersPage_ShowsAdminUser()
|
||||
{
|
||||
var usersPage = new UsersPage(_page);
|
||||
await usersPage.NavigateAsync();
|
||||
|
||||
await Assertions.Expect(usersPage.User(TestConstants.AdminUserName)).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsersPage_CanSearchUsers()
|
||||
{
|
||||
var usersPage = new UsersPage(_page);
|
||||
await usersPage.NavigateAsync();
|
||||
|
||||
await usersPage.SearchUsersAsync(TestConstants.AdminUserName);
|
||||
|
||||
await Assertions.Expect(usersPage.User(TestConstants.AdminUserName)).ToBeVisibleAsync();
|
||||
// After searching for "admin", the admin user must be in the results
|
||||
var count = await usersPage.GetUserCountAsync();
|
||||
Assert.True(count >= 1, $"Expected at least 1 user matching 'admin', got {count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsersPage_ShowsUserRoles()
|
||||
{
|
||||
var usersPage = new UsersPage(_page);
|
||||
await usersPage.NavigateAsync();
|
||||
|
||||
var roles = await usersPage.GetUserRolesTextAsync(TestConstants.AdminUserName);
|
||||
Assert.Contains("Administrator", roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_NewUser_AppearsInUserList()
|
||||
{
|
||||
var testUserName = $"testuser_{Guid.NewGuid().ToString()[..8]}";
|
||||
|
||||
// Register a new user via the public Register page using a separate anonymous context
|
||||
var (anonContext, anonPage) = await _fixture.CreateAnonymousPageAsync();
|
||||
try
|
||||
{
|
||||
await anonPage.GotoAsync("/Register");
|
||||
await anonPage.WaitForSelectorAsync("#login-submit", new() { Timeout = 10000 });
|
||||
|
||||
await anonPage.Locator("input[name='Model.UserName']").FillAsync(testUserName);
|
||||
await anonPage.Locator("input[name='Model.Password']").FillAsync(TestConstants.AdminPassword);
|
||||
await anonPage.Locator("input[name='Model.PasswordConfirmation']").FillAsync(TestConstants.AdminPassword);
|
||||
await anonPage.GetByRole(AriaRole.Button, new() { Name = "Register" }).ClickAsync();
|
||||
|
||||
// Wait for registration to complete (redirects to home)
|
||||
await anonPage.WaitForURLAsync("**/", new() { Timeout = 10000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await anonPage.CloseAsync();
|
||||
await anonContext.DisposeAsync();
|
||||
}
|
||||
|
||||
// Navigate to users page as admin and verify the new user appears
|
||||
var usersPage = new UsersPage(_page);
|
||||
await usersPage.NavigateAsync();
|
||||
|
||||
await Assertions.Expect(usersPage.User(testUserName)).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsersPage_CanDeleteUser()
|
||||
{
|
||||
var testUserName = $"deluser_{Guid.NewGuid().ToString()[..8]}";
|
||||
|
||||
// Create a user via the Register page
|
||||
var (anonContext, anonPage) = await _fixture.CreateAnonymousPageAsync();
|
||||
try
|
||||
{
|
||||
await anonPage.GotoAsync("/Register");
|
||||
await anonPage.WaitForSelectorAsync("#login-submit", new() { Timeout = 10000 });
|
||||
|
||||
await anonPage.Locator("input[name='Model.UserName']").FillAsync(testUserName);
|
||||
await anonPage.Locator("input[name='Model.Password']").FillAsync(TestConstants.AdminPassword);
|
||||
await anonPage.Locator("input[name='Model.PasswordConfirmation']").FillAsync(TestConstants.AdminPassword);
|
||||
await anonPage.GetByRole(AriaRole.Button, new() { Name = "Register" }).ClickAsync();
|
||||
|
||||
await anonPage.WaitForURLAsync("**/", new() { Timeout = 10000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await anonPage.CloseAsync();
|
||||
await anonContext.DisposeAsync();
|
||||
}
|
||||
|
||||
// Navigate to users page as admin
|
||||
var usersPage = new UsersPage(_page);
|
||||
await usersPage.NavigateAsync();
|
||||
|
||||
// Verify user exists before deletion
|
||||
await Assertions.Expect(usersPage.User(testUserName)).ToBeVisibleAsync();
|
||||
|
||||
// Delete the user
|
||||
await usersPage.DeleteUserAsync(testUserName);
|
||||
|
||||
// Verify user is gone
|
||||
await Assertions.Expect(usersPage.User(testUserName)).ToBeHiddenAsync();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue