File: Utils\TestDistributedApplicationBuilder.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Aspire.Components.Common.Tests;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Tests.Dcp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
 
namespace Aspire.Hosting.Utils;
 
/// <summary>
/// DistributedApplication.CreateBuilder() creates a builder that includes configuration to read from appsettings.json.
/// The builder has a FileSystemWatcher, which can't be cleaned up unless a DistributedApplication is built and disposed.
/// This class wraps the builder and provides a way to automatically dispose it to prevent test failures from excessive
/// FileSystemWatcher instances from many tests.
/// </summary>
public sealed class TestDistributedApplicationBuilder : IDistributedApplicationBuilder, IDisposable
{
    private readonly DistributedApplicationBuilder _innerBuilder;
    private bool _disposedValue;
    private DistributedApplication? _app;
 
    public static TestDistributedApplicationBuilder Create(DistributedApplicationOperation operation)
    {
        var args = operation switch
        {
            DistributedApplicationOperation.Run => (string[])[],
            DistributedApplicationOperation.Publish => ["Publishing:Publisher=manifest"],
            _ => throw new ArgumentOutOfRangeException(nameof(operation))
        };
 
        return Create(args);
    }
 
    // Returns the unique prefix used for volumes from unnamed volumes this builder
    public string GetVolumePrefix() =>
        $"{VolumeNameGenerator.Sanitize(Environment.ApplicationName).ToLowerInvariant()}-{Configuration["AppHost:Sha256"]!.ToLowerInvariant()[..10]}";
 
    public static TestDistributedApplicationBuilder Create(params string[] args)
    {
        return new TestDistributedApplicationBuilder(options => options.Args = args);
    }
 
    public static TestDistributedApplicationBuilder Create(ITestOutputHelper testOutputHelper, params string[] args)
    {
        return new TestDistributedApplicationBuilder(options => options.Args = args, testOutputHelper);
    }
 
    public static TestDistributedApplicationBuilder Create(Action<DistributedApplicationOptions>? configureOptions, ITestOutputHelper? testOutputHelper = null)
    {
        return new TestDistributedApplicationBuilder(configureOptions, testOutputHelper);
    }
 
    public static TestDistributedApplicationBuilder CreateWithTestContainerRegistry(ITestOutputHelper testOutputHelper) =>
        Create(o => o.ContainerRegistryOverride = ComponentTestConstants.AspireTestContainerRegistry, testOutputHelper);
 
    private TestDistributedApplicationBuilder(Action<DistributedApplicationOptions>? configureOptions, ITestOutputHelper? testOutputHelper = null)
    {
        var appAssembly = typeof(TestDistributedApplicationBuilder).Assembly;
        var assemblyName = appAssembly.FullName;
 
        _innerBuilder = BuilderInterceptor.CreateBuilder(Configure);
 
        _innerBuilder.Services.Configure<DashboardOptions>(o =>
        {
            // Make sure we have a dashboard URL and OTLP endpoint URL (but don't overwrite them if they're already set)
            o.DashboardUrl ??= "http://localhost:8080";
            o.OtlpGrpcEndpointUrl ??= "http://localhost:4317";
        });
 
        _innerBuilder.Services.AddSingleton<ApplicationExecutorProxy>(sp => new ApplicationExecutorProxy(sp.GetRequiredService<ApplicationExecutor>()));
 
        _innerBuilder.Services.AddHttpClient();
        _innerBuilder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());
        if (testOutputHelper is not null)
        {
            WithTestAndResourceLogging(testOutputHelper);
        }
 
        void Configure(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostBuilderOptions)
        {
            hostBuilderOptions.EnvironmentName = Environments.Development;
            hostBuilderOptions.ApplicationName = appAssembly.GetName().Name;
            applicationOptions.AssemblyName = assemblyName;
            applicationOptions.DisableDashboard = true;
            var cfg = hostBuilderOptions.Configuration ??= new();
            cfg.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["DcpPublisher:RandomizePorts"] = "true",
                ["DcpPublisher:DeleteResourcesOnShutdown"] = "true",
                ["DcpPublisher:ResourceNameSuffix"] = $"{Random.Shared.Next():x}",
            });
 
            configureOptions?.Invoke(applicationOptions);
        }
    }
 
    public TestDistributedApplicationBuilder WithTestAndResourceLogging(ITestOutputHelper testOutputHelper)
    {
        Services.AddXunitLogging(testOutputHelper);
        Services.AddHostedService<ResourceLoggerForwarderService>();
        Services.AddLogging(builder =>
        {
            builder.AddFilter("Aspire.Hosting", LogLevel.Trace);
            builder.SetMinimumLevel(LogLevel.Trace);
        });
        return this;
    }
 
    public ConfigurationManager Configuration => _innerBuilder.Configuration;
 
    public string AppHostDirectory => _innerBuilder.AppHostDirectory;
 
    public Assembly? AppHostAssembly => _innerBuilder.AppHostAssembly;
 
    public IHostEnvironment Environment => _innerBuilder.Environment;
 
    public IServiceCollection Services => _innerBuilder.Services;
 
    public DistributedApplicationExecutionContext ExecutionContext => _innerBuilder.ExecutionContext;
 
    public IResourceCollection Resources => _innerBuilder.Resources;
 
    public IDistributedApplicationEventing Eventing => _innerBuilder.Eventing;
 
    public IResourceBuilder<T> AddResource<T>(T resource) where T : IResource => _innerBuilder.AddResource(resource);
 
    [MemberNotNull(nameof(_app))]
    public DistributedApplication Build() => _app = _innerBuilder.Build();
 
    public Task<DistributedApplication> BuildAsync(CancellationToken cancellationToken = default) => Task.FromResult(Build());
 
    public IResourceBuilder<T> CreateResourceBuilder<T>(T resource) where T : IResource
    {
        return _innerBuilder.CreateResourceBuilder(resource);
    }
 
    public void Dispose()
    {
        if (!_disposedValue)
        {
            _disposedValue = true;
            if (_app is null)
            {
                try
                {
                    Build();
                }
                catch
                {
                }
            }
 
            _app?.Dispose();
        }
    }
 
    private sealed class BuilderInterceptor : IObserver<DiagnosticListener>
    {
        private static readonly ThreadLocal<BuilderInterceptor?> s_currentListener = new();
        private readonly ApplicationBuilderDiagnosticListener _applicationBuilderListener;
        private readonly Action<DistributedApplicationOptions, HostApplicationBuilderSettings>? _onConstructing;
 
        private BuilderInterceptor(Action<DistributedApplicationOptions, HostApplicationBuilderSettings>? onConstructing)
        {
            _onConstructing = onConstructing;
            _applicationBuilderListener = new(this);
        }
 
        public static DistributedApplicationBuilder CreateBuilder(Action<DistributedApplicationOptions, HostApplicationBuilderSettings> onConstructing)
        {
            var interceptor = new BuilderInterceptor(onConstructing);
            var original = s_currentListener.Value;
            s_currentListener.Value = interceptor;
            try
            {
                using var subscription = DiagnosticListener.AllListeners.Subscribe(interceptor);
                return new DistributedApplicationBuilder([]);
            }
            finally
            {
                s_currentListener.Value = original;
            }
        }
 
        public void OnCompleted()
        {
        }
 
        public void OnError(Exception error)
        {
 
        }
 
        public void OnNext(DiagnosticListener value)
        {
            if (s_currentListener.Value != this)
            {
                // Ignore events that aren't for this listener
                return;
            }
 
            if (value.Name == "Aspire.Hosting")
            {
                _applicationBuilderListener.Subscribe(value);
            }
        }
 
        private sealed class ApplicationBuilderDiagnosticListener(BuilderInterceptor owner) : IObserver<KeyValuePair<string, object?>>
        {
            private IDisposable? _disposable;
 
            public void Subscribe(DiagnosticListener listener)
            {
                _disposable = listener.Subscribe(this);
            }
 
            public void OnCompleted()
            {
                _disposable?.Dispose();
            }
 
            public void OnError(Exception error)
            {
            }
 
            public void OnNext(KeyValuePair<string, object?> value)
            {
                if (s_currentListener.Value != owner)
                {
                    // Ignore events that aren't for this listener
                    return;
                }
 
                if (value.Key == "DistributedApplicationBuilderConstructing")
                {
                    var (options, innerBuilderOptions) = ((DistributedApplicationOptions, HostApplicationBuilderSettings))value.Value!;
                    owner._onConstructing?.Invoke(options, innerBuilderOptions);
                }
            }
        }
    }
}