File: DistributedApplicationTestingBuilder.cs
Web Access
Project: src\src\Aspire.Hosting.Testing\Aspire.Hosting.Testing.csproj (Aspire.Hosting.Testing)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPIPELINES001
#pragma warning disable ASPIREUSERSECRETS001
#pragma warning disable ASPIREFILESYSTEM001
 
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Pipelines;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
 
namespace Aspire.Hosting.Testing;
 
/// <summary>
/// Methods for creating distributed application instances for testing purposes.
/// </summary>
public static class DistributedApplicationTestingBuilder
{
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </summary>
    /// <typeparam name="TEntryPoint">
    /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used.
    /// </typeparam>
    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")]
    public static Task<IDistributedApplicationTestingBuilder> CreateAsync<TEntryPoint>(CancellationToken cancellationToken = default)
        where TEntryPoint : class
        => CreateAsync(typeof(TEntryPoint), cancellationToken);
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/> using the specified testing options.
    /// </summary>
    /// <typeparam name="TEntryPoint">
    /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used.
    /// </typeparam>
    /// <param name="options">The options that configure behavior selected while the underlying builder is constructed.</param>
    /// <param name="args">The command line arguments to pass to the entry point.</param>
    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    /// <exception cref="ArgumentNullException">
    /// Thrown when <paramref name="options"/> or <paramref name="args"/> is <see langword="null"/>, or when
    /// <paramref name="args"/> contains a <see langword="null"/> value.
    /// </exception>
    /// <exception cref="ArgumentException">Thrown when <paramref name="args"/> contains an empty value.</exception>
    /// <exception cref="InvalidOperationException">
    /// Thrown when <see cref="DistributedApplicationTestingBuilderOptions.EnableDashboard"/> is enabled in publish mode.
    /// </exception>
    /// <remarks>
    /// The <paramref name="args"/> parameter is required so calls such as <c>CreateAsync&lt;TEntryPoint&gt;(default)</c>
    /// continue to bind to the existing cancellation-token overload.
    /// </remarks>
    [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")]
    public static Task<IDistributedApplicationTestingBuilder> CreateAsync<TEntryPoint>(
        DistributedApplicationTestingBuilderOptions options,
        string[] args,
        CancellationToken cancellationToken = default)
        where TEntryPoint : class
    {
        ArgumentNullException.ThrowIfNull(options);
 
        return CreateAsync(typeof(TEntryPoint), options, args, cancellationToken);
    }
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </summary>
    /// <param name="entryPoint">A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used.</param>
    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")]
    public static Task<IDistributedApplicationTestingBuilder> CreateAsync(Type entryPoint, CancellationToken cancellationToken = default)
        => CreateAsync(entryPoint, [], cancellationToken);
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/> using the specified testing options.
    /// </summary>
    /// <param name="entryPoint">A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used.</param>
    /// <param name="options">The options that configure behavior selected while the underlying builder is constructed.</param>
    /// <param name="args">The command line arguments to pass to the entry point.</param>
    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    /// <exception cref="ArgumentNullException">
    /// Thrown when <paramref name="entryPoint"/>, <paramref name="options"/>, or <paramref name="args"/> is
    /// <see langword="null"/>, or when <paramref name="args"/> contains a <see langword="null"/> value.
    /// </exception>
    /// <exception cref="ArgumentException">Thrown when <paramref name="args"/> contains an empty value.</exception>
    /// <exception cref="InvalidOperationException">
    /// Thrown when <see cref="DistributedApplicationTestingBuilderOptions.EnableDashboard"/> is enabled in publish mode.
    /// </exception>
    /// <remarks>
    /// The <paramref name="args"/> parameter is required so calls such as <c>CreateAsync(entryPoint, default)</c>
    /// continue to bind to the existing cancellation-token overload.
    /// </remarks>
    [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")]
    public static Task<IDistributedApplicationTestingBuilder> CreateAsync(
        Type entryPoint,
        DistributedApplicationTestingBuilderOptions options,
        string[] args,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(options);
 
        return CreateAsyncCore(entryPoint, args, options, (_, __) => { }, cancellationToken);
    }
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </summary>
    /// <typeparam name="TEntryPoint">
    /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used.
    /// </typeparam>
    /// <param name="args">The command line arguments to pass to the entry point.</param>
    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")]
    public static Task<IDistributedApplicationTestingBuilder> CreateAsync<TEntryPoint>(string[] args, CancellationToken cancellationToken = default)
        where TEntryPoint : class
        => CreateAsync(typeof(TEntryPoint), args, (_, __) => { }, cancellationToken);
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </summary>
    /// <param name="entryPoint">A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used.</param>
    /// <param name="args">The command line arguments to pass to the entry point.</param>
    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")]
    public static Task<IDistributedApplicationTestingBuilder> CreateAsync(Type entryPoint, string[] args, CancellationToken cancellationToken = default)
        => CreateAsync(entryPoint, args, (_, __) => { }, cancellationToken);
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </summary>
    /// <typeparam name="TEntryPoint">
    /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used.
    /// </typeparam>
    /// <param name="args">The command line arguments to pass to the entry point.</param>
    /// <param name="configureBuilder">The delegate used to configure the builder.</param>
    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")]
    public static Task<IDistributedApplicationTestingBuilder> CreateAsync<TEntryPoint>(string[] args, Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder, CancellationToken cancellationToken = default)
        => CreateAsync(typeof(TEntryPoint), args, configureBuilder, cancellationToken);
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </summary>
    /// <param name="entryPoint">A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used.</param>
    /// <param name="args">The command line arguments to pass to the entry point.</param>
    /// <param name="configureBuilder">The delegate used to configure the builder.</param>
    /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")]
    public static async Task<IDistributedApplicationTestingBuilder> CreateAsync(Type entryPoint, string[] args, Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder, CancellationToken cancellationToken = default)
        => await CreateAsyncCore(entryPoint, args, testingOptions: null, configureBuilder, cancellationToken).ConfigureAwait(false);
 
    private static async Task<IDistributedApplicationTestingBuilder> CreateAsyncCore(
        Type entryPoint,
        string[] args,
        DistributedApplicationTestingBuilderOptions? testingOptions,
        Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(entryPoint);
        ThrowIfNullOrContainsIsNullOrEmpty(args);
        ArgumentNullException.ThrowIfNull(configureBuilder, nameof(configureBuilder));
 
        var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, testingOptions, configureBuilder);
        try
        {
            return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false);
        }
        catch
        {
            await factory.DisposeAsync().ConfigureAwait(false);
            throw;
        }
    }
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </summary>
    /// <param name="args">The command line arguments to pass to the entry point.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    public static IDistributedApplicationTestingBuilder Create(params string[] args)
        => Create(args, (_, __) => { });
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/> using the specified testing options.
    /// </summary>
    /// <param name="options">The options that configure behavior selected while the underlying builder is constructed.</param>
    /// <param name="args">The command line arguments to use when building the distributed application.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    /// <exception cref="ArgumentNullException">
    /// Thrown when <paramref name="options"/> or <paramref name="args"/> is <see langword="null"/>, or when
    /// <paramref name="args"/> contains a <see langword="null"/> value.
    /// </exception>
    /// <exception cref="ArgumentException">Thrown when <paramref name="args"/> contains an empty value.</exception>
    /// <exception cref="InvalidOperationException">
    /// Thrown when <see cref="DistributedApplicationTestingBuilderOptions.EnableDashboard"/> is enabled in publish mode.
    /// </exception>
    /// <remarks>
    /// The <paramref name="args"/> parameter is required so calls such as <c>Create(default)</c> continue to bind to
    /// the existing command-line-arguments overload.
    /// </remarks>
    public static IDistributedApplicationTestingBuilder Create(
        DistributedApplicationTestingBuilderOptions options,
        string[] args)
    {
        ArgumentNullException.ThrowIfNull(options);
 
        return CreateCore(args, options, (_, __) => { });
    }
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </summary>
    /// <param name="args">The command line arguments to pass to the entry point.</param>
    /// <param name="configureBuilder">The delegate used to configure the builder.</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    public static IDistributedApplicationTestingBuilder Create(string[] args, Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder)
        => CreateCore(args, testingOptions: null, configureBuilder);
 
    private static IDistributedApplicationTestingBuilder CreateCore(
        string[] args,
        DistributedApplicationTestingBuilderOptions? testingOptions,
        Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder,
        Assembly? appHostAssembly = null)
    {
        ThrowIfNullOrContainsIsNullOrEmpty(args);
        ArgumentNullException.ThrowIfNull(configureBuilder);
 
        return new TestingBuilder(args, testingOptions, configureBuilder, appHostAssembly);
    }
 
    /// <summary>
    /// Creates a new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </summary>
    /// <param name="args">The command line arguments to pass to the entry point.</param>
    /// <param name="configureBuilder">The delegate used to configure the builder.</param>
    /// <param name="appHostAssembly">The assembly of app host</param>
    /// <returns>
    /// A new instance of <see cref="IDistributedApplicationTestingBuilder"/>.
    /// </returns>
    internal static IDistributedApplicationTestingBuilder Create(
        string[] args,
        Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder,
        Assembly appHostAssembly)
        => CreateCore(args, testingOptions: null, configureBuilder, appHostAssembly);
 
    private static bool IsDashboardTestingEnabled(DistributedApplicationTestingBuilderOptions? testingOptions)
    {
        // Only the explicit option turns on the hardened testing defaults below. Setting
        // DistributedApplicationOptions.DisableDashboard = false through the configureBuilder callback is the older,
        // already-shipped spelling of "run a dashboard", and it has to keep the behavior it shipped with: callers use
        // it to exercise dashboard behavior against configuration they chose themselves (fixed URLs, anonymous
        // access, an ambient browser token), and in publish mode it is simply ignored because no dashboard resource
        // is ever added. Treating it as equivalent to the option silently rewrote that configuration and turned
        // publish-mode callers into an InvalidOperationException.
        return testingOptions?.EnableDashboard == true;
    }
 
    private static void ConfigureDashboardTesting(
        DistributedApplicationOptions applicationOptions,
        HostApplicationBuilderSettings hostBuilderOptions,
        DistributedApplicationTestingBuilderOptions? testingOptions,
        out DashboardTestingState dashboardTestingState)
    {
        if (!IsDashboardTestingEnabled(testingOptions))
        {
            dashboardTestingState = default;
            return;
        }
 
        var browserToken = TokenGenerator.GenerateToken();
        var resourceServiceApiKey = TokenGenerator.GenerateToken();
 
        dashboardTestingState = new DashboardTestingState(
            Enabled: true,
            BrowserToken: browserToken,
            ResourceServiceApiKey: resourceServiceApiKey);
 
        applicationOptions.DisableDashboard = false;
 
        // Command-line configuration has higher precedence than the configuration sources supplied through
        // HostApplicationBuilderSettings. Append this after the AppHost callback so creation-time arguments and
        // configuration cannot select anonymous dashboard authentication before testing defaults are reapplied.
        //
        // The browser token rides along on the same mechanism, and deliberately not on the in-memory collection
        // below, for two reasons. DistributedApplicationBuilder resolves the token during construction and freezes
        // it into AppHost:BrowserToken, so it has to be visible before the builder is created and there has to be
        // exactly one value. And turning off anonymous access only closes the door if a credential actually exists:
        // without this, an ambient ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN on a CI agent would share a single known
        // token across every test application running there.
        //
        // The resource service API key is seeded the same way and for the same second reason. The builder generates
        // one when configuration does not supply it, but an ambient ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY would
        // otherwise be adopted, giving every test application on that agent the same resource-service credential.
        //
        // DistributedApplicationFactory assigns the same array instance to both properties before the AppHost
        // callback runs, and that callback is free to replace either one. Read whichever instance it left in place,
        // and both when they diverged, so caller arguments are appended to rather than silently dropped.
        string[] existingArgs = ReferenceEquals(hostBuilderOptions.Args, applicationOptions.Args)
            ? [.. hostBuilderOptions.Args ?? []]
            : [.. hostBuilderOptions.Args ?? [], .. applicationOptions.Args ?? []];
 
        hostBuilderOptions.Args =
        [
            .. existingArgs,
            $"{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=false",
            $"{KnownConfigNames.DashboardFrontendBrowserToken}={browserToken}",
            $"{KnownConfigNames.DashboardResourceServiceClientApiKey}={resourceServiceApiKey}"
        ];
        applicationOptions.Args = hostBuilderOptions.Args;
 
        hostBuilderOptions.Configuration ??= new();
        AddDashboardTestingConfiguration(hostBuilderOptions.Configuration);
    }
 
    private static void ConfigureDashboardTesting(IDistributedApplicationBuilder builder, DashboardTestingState dashboardTestingState)
    {
        if (!dashboardTestingState.Enabled)
        {
            return;
        }
 
        if (builder.ExecutionContext.IsPublishMode)
        {
            throw new InvalidOperationException(Properties.Resources.DashboardTestingPublishModeExceptionMessage);
        }
 
        // Apply these after the builder has loaded environment variables and command-line arguments so test
        // automation cannot accidentally opt back into fixed ports, anonymous access, or interactivity.
        // Callers can still override runtime settings through the returned builder; constructor-time service
        // selection, including dashboard authentication, has already completed.
        AddDashboardTestingConfiguration(builder.Configuration);
 
        // Restore the generated browser token if something cleared it. DistributedApplicationBuilder freezes the
        // token into AppHost:BrowserToken during construction, but DashboardOptions does not read that key until the
        // application starts, so AppHost code running between those two points can blank it. DashboardEventHandlers
        // treats a null or empty token as a request for Unsecured frontend authentication, which would silently
        // downgrade the authenticated default this opt-in promises, so the guard mirrors that same emptiness check
        // and leaves any non-empty token, including a deliberately chosen one, alone. This runs after the AppHost
        // entry point has finished configuring, because the builder is not handed back until it reaches Build(), and
        // before the caller sees the builder, so a test that wants the anonymous dashboard can still choose it
        // through the returned builder.
        if (string.IsNullOrEmpty(builder.Configuration["AppHost:BrowserToken"]))
        {
            builder.Configuration["AppHost:BrowserToken"] = dashboardTestingState.BrowserToken;
        }
 
        // Restore resource-service authentication for the same reason and through the same window. The builder
        // freezes AppHost:ResourceService:AuthMode and :ApiKey during construction, but DashboardServiceHost does
        // not bind that section into ResourceServiceOptions until the application starts, so AppHost code running in
        // between can downgrade either one. ResourceServiceApiKeyAuthenticationHandler only checks the API key
        // header when AuthMode is ApiKey and otherwise authenticates every request, and ValidateResourceServiceOptions
        // likewise stops requiring a key once the mode is Unsecured, so clearing either value alone is enough to
        // expose the resource model on the loopback resource-service endpoint. Both halves are restored together
        // because either one on its own leaves the endpoint unauthenticated. Unlike the browser token there is no
        // "deliberately chosen" value to preserve: a caller who supplied their own key still ends up in ApiKey mode
        // with that key, so this only rewrites configuration that would have been unauthenticated.
        if (!string.Equals(builder.Configuration["AppHost:ResourceService:AuthMode"], "ApiKey", StringComparison.OrdinalIgnoreCase)
            || string.IsNullOrEmpty(builder.Configuration["AppHost:ResourceService:ApiKey"]))
        {
            builder.Configuration["AppHost:ResourceService:AuthMode"] = "ApiKey";
            builder.Configuration["AppHost:ResourceService:ApiKey"] = dashboardTestingState.ResourceServiceApiKey;
        }
 
        // Enabling the dashboard makes the hosting default wait indefinitely when a dependency becomes unavailable,
        // which would hang a test run instead of failing it. A later user registration can still override this.
        builder.Services.Configure<ResourceNotificationServiceOptions>(
            options => options.DefaultWaitBehavior = WaitBehavior.StopOnResourceUnavailable);
    }
 
    private static void AddDashboardTestingConfiguration(IConfigurationBuilder configuration)
    {
        configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["DcpPublisher:RandomizePorts"] = "true",
 
            // Empty means "not configured", which is how the product asks for a dynamically assigned port:
            // ConfigureDefaultDashboardOptions normalizes blank to null, and DashboardEventHandlers then creates
            // the endpoint with port: null. Writing an explicit "http://127.0.0.1:0" instead would parse to the
            // fixed port 0 and only behave dynamically while DcpPublisher:RandomizePorts stays true, which a test
            // is free to turn off. These endpoints still bind to loopback because EndpointAnnotation.TargetHost
            // defaults to localhost.
            [KnownAspNetCoreConfigNames.Urls] = string.Empty,
            [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = string.Empty,
            [KnownConfigNames.DashboardOtlpHttpEndpointUrl] = string.Empty,
 
            // The resource service reads its port directly and has first-class handling for port 0.
            [KnownConfigNames.ResourceServiceEndpointUrl] = "http://127.0.0.1:0",
 
            [KnownConfigNames.AllowUnsecuredTransport] = "true",
            [KnownConfigNames.DashboardUnsecuredAllowAnonymous] = "false",
            [KnownConfigNames.InteractivityEnabled] = "false",
 
            // The AppHost and child dashboard each write a startup summary. Under the testing host, both flow into
            // test and CI output, so withhold the live browser credential from each while keeping their endpoint
            // lines. The ASPIRE_DASHBOARD-prefixed value is copied to the child process by DashboardEventHandlers.
            // Tests get the credential through GetDashboardUrlAsync instead.
            ["AppHost:SuppressDashboardLoginUrlInStartupSummary"] = "true",
            [KnownConfigNames.DashboardSuppressBrowserTokenInOutput] = "true"
        });
    }
 
    private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args)
    {
        ArgumentNullException.ThrowIfNull(args);
        foreach (var arg in args)
        {
            if (string.IsNullOrEmpty(arg))
            {
                var values = string.Join(", ", args);
                if (arg is null)
                {
                    throw new ArgumentNullException(nameof(args), $"Array params contains null item: [{values}]");
                }
                throw new ArgumentException($"Array params contains empty item: [{values}]", nameof(args));
            }
        }
    }
 
    /// <summary>
    /// The dashboard testing configuration resolved during builder construction. Carried as a single value so the
    /// pre-construction and post-construction halves of the configuration cannot drift apart.
    /// </summary>
    private readonly record struct DashboardTestingState(bool Enabled, string? BrowserToken, string? ResourceServiceApiKey);
 
    private sealed class SuspendingDistributedApplicationFactory(
        Type entryPoint,
        string[] args,
        DistributedApplicationTestingBuilderOptions? testingOptions,
        Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder)
        : DistributedApplicationFactory(entryPoint, args)
    {
        private readonly SemaphoreSlim _continueBuilding = new(0);
 
        // Resolved while the builder is being constructed, because dashboard services and dashboard authentication
        // are selected during construction and the post-construction half of the configuration has to agree with it.
        private DashboardTestingState _dashboardTestingState;
 
        public async Task<IDistributedApplicationTestingBuilder> CreateBuilderAsync(CancellationToken cancellationToken)
        {
            var innerBuilder = await ResolveBuilderAsync(cancellationToken).ConfigureAwait(false);
            ConfigureDashboardTesting(innerBuilder, _dashboardTestingState);
            return new Builder(this, innerBuilder);
        }
 
        protected override void OnBuilderCreating(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostOptions)
        {
            base.OnBuilderCreating(applicationOptions, hostOptions);
            configureBuilder(applicationOptions, hostOptions);
            ConfigureDashboardTesting(applicationOptions, hostOptions, testingOptions, out _dashboardTestingState);
        }
 
        protected override void OnBuilding(DistributedApplicationBuilder applicationBuilder)
        {
            base.OnBuilding(applicationBuilder);
 
            // Wait until the owner signals that building can continue by calling BuildAsync().
            _continueBuilding.Wait();
        }
 
        public async Task<DistributedApplication> BuildAsync(CancellationToken cancellationToken)
        {
            _continueBuilding.Release();
            return await ResolveApplicationAsync(cancellationToken).ConfigureAwait(false);
        }
 
        public override async ValueTask DisposeAsync()
        {
            _continueBuilding.Release();
            await base.DisposeAsync().ConfigureAwait(false);
        }
 
        public override void Dispose()
        {
            _continueBuilding.Release();
            base.Dispose();
        }
 
        private sealed class Builder(SuspendingDistributedApplicationFactory factory, DistributedApplicationBuilder innerBuilder) : IDistributedApplicationTestingBuilder
        {
            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 IDistributedApplicationPipeline Pipeline => innerBuilder.Pipeline;
 
            public IUserSecretsManager UserSecretsManager => innerBuilder.UserSecretsManager;
 
            public IResourceBuilder<T> AddResource<T>(T resource) where T : IResource => innerBuilder.AddResource(resource);
 
            public DistributedApplication Build() => BuildAsync(CancellationToken.None).Result;
 
            public async Task<DistributedApplication> BuildAsync(CancellationToken cancellationToken)
            {
                var innerApp = await factory.BuildAsync(cancellationToken).ConfigureAwait(false);
                return new DelegatedDistributedApplication(new DelegatedHost(factory, innerApp));
            }
 
            public IResourceBuilder<T> CreateResourceBuilder<T>(T resource) where T : IResource => innerBuilder.CreateResourceBuilder(resource);
 
            public void Dispose()
            {
                factory.Dispose();
            }
 
            public async ValueTask DisposeAsync()
            {
                await factory.DisposeAsync().ConfigureAwait(false);
            }
        }
 
        private sealed class DelegatedDistributedApplication(DelegatedHost host) : DistributedApplication(host)
        {
            private readonly DelegatedHost _host = host;
 
            public override async Task RunAsync(CancellationToken cancellationToken)
            {
                // Avoid calling the base here, since it will execute the pre-start hooks
                // before calling the corresponding host method, which also executes the same pre-start hooks.
                await _host.RunAsync(cancellationToken).ConfigureAwait(false);
            }
 
            public override async Task StartAsync(CancellationToken cancellationToken)
            {
                // Avoid calling the base here, since it will execute the pre-start hooks
                // before calling the corresponding host method, which also executes the same pre-start hooks.
                await _host.StartAsync(cancellationToken).ConfigureAwait(false);
            }
 
            public override async Task StopAsync(CancellationToken cancellationToken)
            {
                await _host.StopAsync(cancellationToken).ConfigureAwait(false);
            }
        }
 
        private sealed class DelegatedHost(SuspendingDistributedApplicationFactory appFactory, DistributedApplication innerApp) : IHost, IAsyncDisposable
        {
            public IServiceProvider Services => innerApp.Services;
 
            public void Dispose()
            {
                appFactory.Dispose();
            }
 
            public async ValueTask DisposeAsync()
            {
                await appFactory.DisposeAsync().ConfigureAwait(false);
            }
 
            public async Task StartAsync(CancellationToken cancellationToken)
            {
                await appFactory.StartAsync(cancellationToken).ConfigureAwait(false);
            }
 
            public async Task StopAsync(CancellationToken cancellationToken)
            {
                await appFactory.DisposeAsync().AsTask().WaitAsync(cancellationToken).ConfigureAwait(false);
            }
        }
    }
 
    private sealed class TestingBuilder(
        string[] args,
        DistributedApplicationTestingBuilderOptions? testingOptions,
        Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder,
        Assembly? appHostAssembly = null)
        : IDistributedApplicationTestingBuilder
    {
        private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, testingOptions, configureBuilder, appHostAssembly);
        private DistributedApplication? _app;
 
        private static DistributedApplicationBuilder CreateInnerBuilder(
            string[] args,
            DistributedApplicationTestingBuilderOptions? testingOptions,
            Action<DistributedApplicationOptions, HostApplicationBuilderSettings> configureBuilder,
            Assembly? appHostAssembly = null)
        {
            var dashboardTestingState = default(DashboardTestingState);
            var builder = TestingBuilderFactory.CreateBuilder(args, onConstructing: (applicationOptions, hostBuilderOptions) =>
            {
                Assembly appAssembly;
                if (appHostAssembly is not null && GetDcpCliPath(appHostAssembly) is { Length: > 0 })
                {
                    appAssembly = appHostAssembly;
                }
                else
                {
                    appAssembly = FindApplicationAssembly();
                }
 
                DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, (options, settings) =>
                {
                    configureBuilder(options, settings);
                    ConfigureDashboardTesting(options, settings, testingOptions, out dashboardTestingState);
                });
            });
 
            ConfigureDashboardTesting(builder, dashboardTestingState);
 
            if (!builder.Configuration.GetValue(KnownConfigNames.TestingDisableHttpClient, false))
            {
                builder.Services.AddHttpClient();
                builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());
            }
 
            return builder;
 
            static Assembly FindApplicationAssembly()
            {
                // Walk the stack trace to find the first assembly that has the 'dcpclipath' metadata attribute.
                // This will be selected as the application host assembly. DCP is necessary to launch the application.
                var stackTrace = new StackTrace();
                foreach (var stackFrame in stackTrace.GetFrames())
                {
                    var asm = stackFrame.GetMethod()?.DeclaringType?.Assembly;
                    if (asm is not null && GetDcpCliPath(asm) is { Length: > 0 })
                    {
                        return asm;
                    }
                }
 
                throw new InvalidOperationException("No application host assembly was found. Ensure that you have a project that references the 'Aspire.Hosting.AppHost' package and imports the 'Aspire.AppHost.Sdk' SDK.");
            }
 
            static string? GetDcpCliPath(Assembly? assembly)
            {
                var assemblyMetadata = assembly?.GetCustomAttributes<AssemblyMetadataAttribute>();
                return assemblyMetadata?.FirstOrDefault(m => string.Equals(m.Key, "dcpclipath", StringComparison.OrdinalIgnoreCase))?.Value;
            }
        }
 
        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 IDistributedApplicationPipeline Pipeline => _innerBuilder.Pipeline;
 
        public IUserSecretsManager UserSecretsManager => _innerBuilder.UserSecretsManager;
 
        public IResourceBuilder<T> AddResource<T>(T resource) where T : IResource => _innerBuilder.AddResource(resource);
 
        [MemberNotNull(nameof(_app))]
        public DistributedApplication Build()
        {
            return _app = _innerBuilder.Build();
        }
 
        public Task<DistributedApplication> BuildAsync(CancellationToken cancellationToken)
        {
            cancellationToken.ThrowIfCancellationRequested();
            return Task.FromResult(Build());
        }
 
        public IResourceBuilder<T> CreateResourceBuilder<T>(T resource) where T : IResource => _innerBuilder.CreateResourceBuilder(resource);
 
        public void Dispose()
        {
            if (_app is null)
            {
                try
                {
                    Build();
                }
                catch
                {
                    // Suppress.
                }
            }
 
            if (_app is { } app)
            {
                app.Dispose();
            }
        }
 
        public async ValueTask DisposeAsync()
        {
            if (_app is null)
            {
                try
                {
                    Build();
                }
                catch
                {
                    // Suppress.
                }
            }
 
            if (_app is IAsyncDisposable asyncDisposable)
            {
                await asyncDisposable.DisposeAsync().ConfigureAwait(false);
            }
        }
    }
}
 
/// <summary>
/// A builder for creating instances of <see cref="DistributedApplication"/> for testing purposes.
/// </summary>
public interface IDistributedApplicationTestingBuilder : IDistributedApplicationBuilder, IAsyncDisposable, IDisposable
{
    /// <inheritdoc cref="IDistributedApplicationBuilder.Configuration" />
    new ConfigurationManager Configuration => ((IDistributedApplicationBuilder)this).Configuration;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.AppHostDirectory" />
    new string AppHostDirectory => ((IDistributedApplicationBuilder)this).AppHostDirectory;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.AppHostAssembly" />
    new Assembly? AppHostAssembly => ((IDistributedApplicationBuilder)this).AppHostAssembly;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.Environment" />
    new IHostEnvironment Environment => ((IDistributedApplicationBuilder)this).Environment;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.Services" />
    new IServiceCollection Services => ((IDistributedApplicationBuilder)this).Services;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.ExecutionContext" />
    new DistributedApplicationExecutionContext ExecutionContext => ((IDistributedApplicationBuilder)this).ExecutionContext;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.Eventing" />
    new IDistributedApplicationEventing Eventing => ((IDistributedApplicationBuilder)this).Eventing;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.Pipeline" />
    new IDistributedApplicationPipeline Pipeline => ((IDistributedApplicationBuilder)this).Pipeline;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.Resources" />
    new IResourceCollection Resources => ((IDistributedApplicationBuilder)this).Resources;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.FileSystemService" />
    new IFileSystemService FileSystemService => ((IDistributedApplicationBuilder)this).FileSystemService;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.UserSecretsManager" />
    new IUserSecretsManager UserSecretsManager => ((IDistributedApplicationBuilder)this).UserSecretsManager;
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.AddResource{T}(T)" />
    new IResourceBuilder<T> AddResource<T>(T resource) where T : IResource => ((IDistributedApplicationBuilder)this).AddResource(resource);
 
    /// <inheritdoc cref="IDistributedApplicationBuilder.CreateResourceBuilder{T}(T)" />
    new IResourceBuilder<T> CreateResourceBuilder<T>(T resource) where T : IResource => ((IDistributedApplicationBuilder)this).CreateResourceBuilder(resource);
 
    /// <summary>
    /// Builds and returns a new <see cref="DistributedApplication"/> instance. This can only be called once.
    /// </summary>
    /// <returns>A new <see cref="DistributedApplication"/> instance.</returns>
    Task<DistributedApplication> BuildAsync(CancellationToken cancellationToken = default);
}