// Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. using Aspire.Hosting; using Microsoft.Extensions.Logging; #if !DEBUG_NCRUNCH using Microsoft.Extensions.Logging.Console; #endif using Serilog; using Serilog.Core; using Serilog.Extensions.Logging; namespace Duende.Xunit.Playwright; public class AppHostFixture(IAppHostServiceRoutes routes) : IAsyncLifetime where THost : class { private readonly TextWriter _startupLogs = new StringWriter(); private WriteTestOutput? _activeWriter; private DistributedApplication? _app; private Logger _logger = null!; public bool UsingAlreadyRunningInstance { get; private set; } public string StartupLogs => _startupLogs.ToString() ?? string.Empty; public async ValueTask InitializeAsync() { using var startupLogWriter = ConnectLogger(s => _startupLogs.Write(s)); var loggerConfiguration = new LoggerConfiguration() .WriteTo .TextWriter(new DelegateTextWriter(WriteLogs), outputTemplate: "{Message} - {SourceContext} {NewLine}"); _logger = loggerConfiguration.CreateLogger(); UsingAlreadyRunningInstance = await CheckIfAspireIsRunning(); if (UsingAlreadyRunningInstance) { WriteLogs("Running tests against real test server"); return; } #if !DEBUG_NCRUNCH // The console logger is cluttering up the test output void RemoveConsoleLogger(ILoggingBuilder x) { var collection = x.Services; for (var i = collection.Count - 1; i >= 0; i--) { var descriptor = collection[i]; if (descriptor.ServiceType == typeof(ILoggerProvider) && descriptor.ImplementationType == typeof(ConsoleLoggerProvider)) { collection.RemoveAt(i); } } } // Not running in ncrunch AND no service found running. // So, create an AppHost that will be used for the duration of this test run. var appHost = await DistributedApplicationTestingBuilder .CreateAsync(); appHost.Configuration["DcpPublisher:RandomizePorts"] = "false"; appHost.Services.ConfigureHttpClientDefaults(c => c.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { UseCookies = false, AllowAutoRedirect = false })); appHost.Services.AddLogging(x => { RemoveConsoleLogger(x); x.AddSerilog(_logger); }); _app = await appHost.BuildAsync(); var resourceNotificationService = _app.Services .GetRequiredService(); await _app.StartAsync(); // Wait for all the services so that their logs are mostly written. foreach (var resource in routes.ServiceNames) { await resourceNotificationService.WaitForResourceAsync( resource, KnownResourceStates.Running ) .WaitAsync(TimeSpan.FromSeconds(30)); } #else _app = null!; #endif //#DEBUG_NCRUNCH } public async ValueTask DisposeAsync() { if (_app != null) { await _app.DisposeAsync(); } } public IDisposable ConnectLogger(WriteTestOutput output) { _activeWriter = output; return new DelegateDisposable(() => _activeWriter = null); } private async Task CheckIfAspireIsRunning() { try { var client = new HttpClient(); client.Timeout = TimeSpan.FromMilliseconds(1000); var response = await client.GetAsync("https://localhost:17052"); if (response.IsSuccessStatusCode) { return true; } return false; } catch (HttpRequestException) { return false; } catch (TaskCanceledException) { return false; } } private void WriteLogs(string logMessage) => _activeWriter?.Invoke(logMessage); /// /// This method builds an http client. /// /// /// public HttpClient CreateHttpClient(string hostName) { HttpMessageHandler inner; Uri? baseAddress; if (UsingAlreadyRunningInstance) { var url = GetUrlTo(hostName); baseAddress = url; inner = new SocketsHttpHandler { // We need to disable cookies and follow redirects // because we do this manually (see below). UseCookies = false, AllowAutoRedirect = false }; } else { #if DEBUG_NCRUNCH // This should not be reached for NCrunch because either the service is already running // or the test base has thrown a SkipException. throw new InvalidOperationException("This should not be reached in NCrunch"); #else // If we're here, that means that we need to create an http client that's pointing to // aspire. if (_app == null) { throw new NotSupportedException("App should not be null"); } var client = _app.CreateHttpClient(hostName); baseAddress = client.BaseAddress; // We can't directly use the HTTP Client, because we need cookie support, but if we // enable that the cookies get shared across multiple requests // https://github.com/dotnet/AspNetCore.Docs/issues/15848 // By wrapping the http client, then handling all the cookies // ourselves, we bypass this problem. inner = new CloningHttpMessageHandler(client); #endif } // Log every call that's made (including if it was part of a redirect). var loggingHandler = new RequestLoggingHandler( CreateLogger() , _ => true) { InnerHandler = inner }; // Manually take care of cookies (see reason why above) var cookieHandler = new CookieHandler(loggingHandler, new CookieContainer()); // Follow redirects when needed. var redirectHandler = new AutoFollowRedirectHandler(CreateLogger()) { InnerHandler = cookieHandler }; // Return an http client that follows redirects, uses cookies and logs all requests. // For aspire, this is needed otherwise cookies are shared, but it also // gives a much clearer debug output (each request gets logged). return new HttpClient(redirectHandler) { BaseAddress = baseAddress }; } public Uri GetUrlTo(string hostName) { if (UsingAlreadyRunningInstance) { return routes.UrlTo(hostName); } else { #if !DEBUG_NCRUNCH if (_app == null) { throw new NullReferenceException("App should not be null"); } return _app.GetEndpoint(hostName); #else Assert.Skip("When running the Host.Tests using NCrunch, you must start the Hosts.AppHost project manually. IE: dotnet run -p bff/samples/Hosts.AppHost. Or start without debugging from the UI. "); return null!; #endif } } private ILogger CreateLogger() { var loggerProvider = new SerilogLoggerProvider(_logger); return new LoggerFactory([loggerProvider]).CreateLogger(); } }