| File: Infrastructure\ServerFixture.cs | Web Access |
| Project: src\aspnetcore\src\Components\Testing\src\Microsoft.AspNetCore.Components.Testing.csproj (Microsoft.AspNetCore.Components.Testing) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; using System.Linq; using System.Net; using System.Net.Sockets; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Xunit; using Yarp.ReverseProxy.Configuration; namespace Microsoft.AspNetCore.Components.Testing.Infrastructure; /// <summary> /// xUnit V3 collection fixture that manages the full E2E test lifecycle: /// loads the E2E manifest, runs a YARP reverse proxy on a dynamic port, /// and starts app instances on demand via <see cref="StartServerAsync{TApp}"/>. /// </summary> /// <typeparam name="TTestAssembly"> /// A marker type defined in the test project (e.g., <c>public class E2ETestAssembly;</c>). /// Used to locate the test assembly, which determines the manifest filename /// and the assembly path for <c>DOTNET_STARTUP_HOOKS</c> and <c>ASPNETCORE_HOSTINGSTARTUPASSEMBLIES</c>. /// </typeparam> /// <remarks> /// <para> /// Tests call <see cref="StartServerAsync{TApp}"/> to start apps. /// <c>StartServerAsync</c> is idempotent: same app + same options = same running instance. /// </para> /// <para> /// Usage: /// <code> /// public class E2ETestAssembly; /// /// [CollectionDefinition(nameof(E2EAppCollection))] /// public class E2EAppCollection : ICollectionFixture<ServerFixture<E2ETestAssembly>>; /// /// [Collection(nameof(E2EAppCollection))] /// public class MyTests : BrowserTest /// { /// private readonly ServerFixture<E2ETestAssembly> _fixture; /// public MyTests(ServerFixture<E2ETestAssembly> fixture) => _fixture = fixture; /// } /// </code> /// </para> /// </remarks> public class ServerFixture<TTestAssembly> : IAsyncLifetime where TTestAssembly : class { private WebApplication? _proxyApp; private InMemoryConfigProvider? _configProvider; private readonly ConcurrentDictionary<string, ServerInstance> _instances = new(); private readonly ConcurrentDictionary<string, TaskCompletionSource> _readinessSignals = new(); private readonly SemaphoreSlim _startLock = new(1, 1); private string _testAssemblyLocation = ""; private string _testAssemblyName = ""; internal E2EManifest Manifest { get; private set; } = default!; /// <summary> /// Base URL of the YARP reverse proxy (e.g., <c>http://localhost:12345</c>). /// </summary> public string ProxyUrl { get; private set; } = ""; /// <inheritdoc/> public async ValueTask InitializeAsync() { // Resolve test assembly info via the TTestAssembly marker type var assembly = typeof(TTestAssembly).Assembly; _testAssemblyName = assembly.GetName().Name!; _testAssemblyLocation = assembly.Location; // Load the manifest generated by Microsoft.AspNetCore.Components.Testing.targets Manifest = E2EManifest.Load(_testAssemblyName); // Start the YARP reverse proxy on a dynamic port var port = GetAvailablePort(); ProxyUrl = $"http://localhost:{port}"; var builder = WebApplication.CreateSlimBuilder(); builder.Services.AddRoutingCore(); builder.Services.AddReverseProxy().LoadFromMemory([], []); _proxyApp = builder.Build(); // Readiness callback endpoint: app processes POST here when they're ready. // Each token maps to a TaskCompletionSource created in StartServerAsync. _proxyApp.MapPost("/_ready/{token}", (string token) => { if (_readinessSignals.TryRemove(token, out var tcs)) { tcs.TrySetResult(); return Results.Ok(); } return Results.NotFound(); }); _proxyApp.MapReverseProxy(); _proxyApp.Urls.Add(ProxyUrl); await _proxyApp.StartAsync().ConfigureAwait(false); _configProvider = _proxyApp.Services.GetRequiredService<InMemoryConfigProvider>(); } /// <summary> /// Starts (or retrieves) an app instance. Idempotent: same app + same options = same instance. /// Derives the app name from <c>typeof(TApp).Assembly.GetName().Name</c>. /// Use any public type from the app's assembly (e.g., a Razor component like <c>App</c>). /// </summary> /// <typeparam name="TApp">Any public type from the app's assembly.</typeparam> /// <param name="configure">Optional callback to configure start options.</param> /// <returns>A <see cref="ServerInstance"/> with the proxy routing info needed for tests.</returns> public Task<ServerInstance> StartServerAsync<TApp>(Action<ServerStartOptions>? configure = null) { var appName = typeof(TApp).Assembly.GetName().Name ?? throw new InvalidOperationException( $"Could not determine assembly name for type '{typeof(TApp).FullName}'."); return StartServerAsync(appName, configure); } /// <summary> /// Starts (or retrieves) an app instance by name. Idempotent: same app + same options = same instance. /// </summary> /// <param name="appName">The app name as declared in the E2E manifest.</param> /// <param name="configure">Optional callback to configure start options.</param> /// <returns>A <see cref="ServerInstance"/> with the proxy routing info needed for tests.</returns> public async Task<ServerInstance> StartServerAsync( string appName, Action<ServerStartOptions>? configure = null) { var options = new ServerStartOptions(); configure?.Invoke(options); var key = ServerInstance.ComputeKey(appName, options); if (_instances.TryGetValue(key, out var existing)) { return existing; } await _startLock.WaitAsync().ConfigureAwait(false); try { // Double-check after acquiring lock if (_instances.TryGetValue(key, out existing)) { return existing; } var appEntry = Manifest.GetApp(appName) ?? throw new InvalidOperationException( $"App '{appName}' not found in E2E manifest. " + $"Available apps: {string.Join(", ", Manifest.Apps.Keys)}"); var instance = new ServerInstance(appName, key, ProxyUrl, instanceKey => { _instances.TryRemove(instanceKey, out _); UpdateProxyConfig(); }); // Create a readiness signal: the app will POST to /_ready/{token} // when it is listening, completing this TCS. var readyToken = Guid.NewGuid().ToString("N"); var readyTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _readinessSignals[readyToken] = readyTcs; var readyUrl = $"{ProxyUrl}/_ready/{readyToken}"; try { await instance.LaunchAsync(appEntry, options, _testAssemblyLocation, _testAssemblyName, readyUrl, readyTcs.Task).ConfigureAwait(false); } finally { // Clean up stale signal if launch failed before the app called back _readinessSignals.TryRemove(readyToken, out _); } _instances[key] = instance; UpdateProxyConfig(); return instance; } finally { _startLock.Release(); } } /// <inheritdoc/> public async ValueTask DisposeAsync() { // Dispose all running instances foreach (var instance in _instances.Values) { try { await instance.DisposeAsync().ConfigureAwait(false); } catch { // Best-effort cleanup } } _instances.Clear(); // Stop the YARP proxy if (_proxyApp is not null) { await _proxyApp.StopAsync().ConfigureAwait(false); await _proxyApp.DisposeAsync().ConfigureAwait(false); } _startLock.Dispose(); } void UpdateProxyConfig() { if (_configProvider is null) { return; } var routes = _instances.Values.Select(i => new RouteConfig { RouteId = i.Id, ClusterId = i.Id, Match = new RouteMatch { Path = "{**catch-all}", Headers = [ new RouteHeader { Name = "X-Test-Backend", Values = [i.Id], Mode = HeaderMatchMode.ExactHeader, } ] } }).ToArray(); var clusters = _instances.Values.Select(i => new ClusterConfig { ClusterId = i.Id, Destinations = new Dictionary<string, DestinationConfig> { ["default"] = new() { Address = i.AppUrl } } }).ToArray(); _configProvider.Update(routes, clusters); } static int GetAvailablePort() { var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } }