Events-MVC (example with htmx)

This commit is contained in:
Boris Milašinović
2026-04-25 22:21:35 +02:00
parent eb04483417
commit 0ee1b22f61
114 changed files with 7966 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Microsoft.Playwright" Version="1.59.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Events.MVC\Events.MVC.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,77 @@
using Events.Tests.UITests.Infrastructure;
using Microsoft.Playwright;
namespace Events.Tests.UITests;
public class HomeAndSportsPageTests
{
[Fact]
public async Task HomePageShouldDisplayEnglishDescription()
{
await using var harness = await UiTestHarness.CreateAsync();
await harness.Page.GotoAsync($"{harness.RootUrl}/");
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Events" })).ToBeVisibleAsync();
await Assertions.Expect(harness.Page.GetByText("This sample demonstrates an ASP.NET Core MVC application")).ToBeVisibleAsync();
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Link, new() { Name = "Sports" })).ToBeVisibleAsync();
}
[Fact]
public async Task SportsPageShouldDisplayExistingSportsAndAllowOpeningCreatePanel()
{
await using var harness = await UiTestHarness.CreateAsync();
var expectedSports = new[] { "Running", "Chess", "Swimming" };
await harness.Page.GotoAsync($"{harness.RootUrl}/Sports");
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Sports list" })).ToBeVisibleAsync();
await Assertions.Expect(harness.Page.Locator("#sports-table-body tr").First).ToBeVisibleAsync();
await Assertions.Expect(harness.Page.GetByPlaceholder("Search by sport name")).ToBeVisibleAsync();
foreach (var expectedSport in expectedSports)
{
await Assertions.Expect(harness.Page.Locator("#sports-list")).ToContainTextAsync(expectedSport);
}
await harness.Page.GetByRole(AriaRole.Button, new() { Name = "New sport" }).ClickAsync();
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Add a new sport" })).ToBeVisibleAsync();
await Assertions.Expect(
harness.Page.Locator("#create-sport-form").GetByLabel("Name", new() { Exact = true }))
.ToBeVisibleAsync();
}
[Fact]
public async Task SportsPageShouldCreateSportAndShowSuccessToast()
{
await using var harness = await UiTestHarness.CreateAsync();
var sportName = $"UI Test Sport {Guid.NewGuid():N}";
await harness.Page.GotoAsync($"{harness.RootUrl}/Sports");
await harness.Page.GetByRole(AriaRole.Button, new() { Name = "New sport" }).ClickAsync();
var createForm = harness.Page.Locator("#create-sport-form");
await createForm.GetByLabel("Name", new() { Exact = true }).FillAsync(sportName);
await createForm.GetByRole(AriaRole.Button, new() { Name = "Add sport" }).ClickAsync();
await Assertions.Expect(harness.Page.Locator("#app-toast")).ToBeVisibleAsync();
await Assertions.Expect(harness.Page.Locator("#app-toast-title")).ToHaveTextAsync("Success");
await Assertions.Expect(harness.Page.Locator("#app-toast-body")).ToContainTextAsync($"Sport '{sportName}' was added successfully.");
await harness.Page.GetByPlaceholder("Search by sport name").FillAsync(sportName);
await harness.Page.GetByRole(AriaRole.Button, new() { Name = "Filter" }).ClickAsync();
await Assertions.Expect(harness.Page.Locator("#sports-list")).ToContainTextAsync(sportName);
var sportRow = harness.Page.Locator("#sports-table-body tr").Filter(new() { HasText = sportName });
await Assertions.Expect(sportRow).ToHaveCountAsync(1);
harness.Page.Dialog += async (_, dialog) => await dialog.AcceptAsync();
await sportRow.GetByRole(AriaRole.Button, new() { Name = "Delete" }).ClickAsync();
await Assertions.Expect(harness.Page.Locator("#app-toast")).ToBeVisibleAsync();
await Assertions.Expect(harness.Page.Locator("#app-toast-title")).ToHaveTextAsync("Success");
await Assertions.Expect(harness.Page.Locator("#app-toast-body")).ToContainTextAsync($"Sport '{sportName}' was deleted successfully.");
await Assertions.Expect(harness.Page.Locator("#sports-list")).Not.ToContainTextAsync(sportName);
}
}

View File

@@ -0,0 +1,196 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Playwright;
namespace Events.Tests.UITests.Infrastructure;
internal sealed class UiTestHarness : IAsyncDisposable
{
private readonly IPlaywright playwright;
private readonly Process appProcess;
private readonly StringBuilder processOutput;
private UiTestHarness(
IPlaywright playwright,
IBrowser browser,
IBrowserContext browserContext,
IPage page,
Process appProcess,
StringBuilder processOutput,
string rootUrl)
{
this.playwright = playwright;
this.appProcess = appProcess;
this.processOutput = processOutput;
Browser = browser;
BrowserContext = browserContext;
Page = page;
RootUrl = rootUrl;
}
public IBrowser Browser { get; }
public IBrowserContext BrowserContext { get; }
public IPage Page { get; }
public string RootUrl { get; }
public static async Task<UiTestHarness> CreateAsync()
{
var port = FindFreePort();
var rootUrl = $"http://127.0.0.1:{port}";
var mvcProjectPath = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"..", "..", "..", "..", "..",
"Events.MVC", "Events.MVC.csproj"));
var processOutput = new StringBuilder();
var startInfo = new ProcessStartInfo("dotnet", $"run --project \"{mvcProjectPath}\" --urls {rootUrl}")
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "UITest";
#if POSTGRES
startInfo.Environment["ConnectionStrings__EventsPostgres"] = ResolveUiTestConnectionString();
#else
startInfo.Environment["ConnectionStrings__EventsMssql"] = ResolveUiTestConnectionString();
#endif
var appProcess = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start the MVC app process for UI tests.");
appProcess.OutputDataReceived += (_, args) =>
{
if (args.Data is not null)
{
processOutput.AppendLine(args.Data);
}
};
appProcess.ErrorDataReceived += (_, args) =>
{
if (args.Data is not null)
{
processOutput.AppendLine(args.Data);
}
};
appProcess.BeginOutputReadLine();
appProcess.BeginErrorReadLine();
await WaitForServerAsync(rootUrl, appProcess, processOutput);
IPlaywright playwright;
try
{
playwright = await Playwright.CreateAsync();
}
catch
{
await StopProcessAsync(appProcess);
throw;
}
try
{
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
var browserContext = await browser.NewContextAsync(new BrowserNewContextOptions
{
BaseURL = rootUrl
});
var page = await browserContext.NewPageAsync();
return new UiTestHarness(playwright, browser, browserContext, page, appProcess, processOutput, rootUrl);
}
catch (PlaywrightException)
{
await StopProcessAsync(appProcess);
playwright.Dispose();
throw new InvalidOperationException(
"Playwright browser is not installed. Run 'dotnet tool install --global Microsoft.Playwright.CLI' and then 'playwright install'.");
}
}
public async ValueTask DisposeAsync()
{
await BrowserContext.DisposeAsync();
await Browser.DisposeAsync();
playwright.Dispose();
await StopProcessAsync(appProcess);
}
private static int FindFreePort()
{
using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
}
private static async Task WaitForServerAsync(string rootUrl, Process appProcess, StringBuilder processOutput)
{
using var httpClient = new HttpClient();
var timeoutAt = DateTime.UtcNow.AddSeconds(30);
while (DateTime.UtcNow < timeoutAt)
{
if (appProcess.HasExited)
{
throw new InvalidOperationException(
$"MVC app process exited before the UI test server became ready.{Environment.NewLine}{processOutput}");
}
try
{
using var response = await httpClient.GetAsync(rootUrl);
if ((int)response.StatusCode < 500)
{
return;
}
}
catch
{
}
await Task.Delay(250);
}
throw new InvalidOperationException(
$"Timed out while waiting for the UI test server at {rootUrl}.{Environment.NewLine}{processOutput}");
}
private static string ResolveUiTestConnectionString()
{
var configuration = new ConfigurationBuilder()
.AddUserSecrets<Program>(optional: true)
.Build();
var connectionString = configuration.GetConnectionString("EventDB-Test");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"The EventDB-Test connection string must be available so UI tests can connect to the selected provider's test database.");
}
return connectionString;
}
private static async Task StopProcessAsync(Process appProcess)
{
if (appProcess.HasExited)
{
appProcess.Dispose();
return;
}
appProcess.Kill(entireProcessTree: true);
await appProcess.WaitForExitAsync();
appProcess.Dispose();
}
}

View File

@@ -0,0 +1,43 @@
# Events.Tests.UITests
This project contains Playwright-based UI tests for `Events-MVC`.
## Prerequisites
- .NET SDK 10.0
- Playwright CLI
- Playwright browser binaries
## Playwright Installation
Install the Playwright CLI once:
```powershell
dotnet tool install --global Microsoft.Playwright.CLI
```
Install browser binaries:
```powershell
playwright install
```
## Running the UI Tests
Run the full UI test project:
```powershell
dotnet test Events-MVC\Tests\Events.Tests.UITests\Events.Tests.UITests.csproj
```
Run a single test:
```powershell
dotnet test Events-MVC\Tests\Events.Tests.UITests\Events.Tests.UITests.csproj --filter HomeAndSportsPageTests.HomePageShouldDisplayEnglishDescription
```
## Notes
- The UI test harness starts the MVC application automatically
- UI tests connect the MVC application to the selected provider's test database from `ConnectionStrings:EventDB-Test`
- The browser is currently configured in headless mode