From 58017afe3c9d524018dd5290512467f192c66c93 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 01:43:27 -0400 Subject: [PATCH 01/21] Add dashboard support to testing builders Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb --- .../Aspire.Hosting.Testing.csproj | 7 - ...utedApplicationHostingTestingExtensions.cs | 94 ++++++++- .../DistributedApplicationTestingBuilder.cs | 187 ++++++++++++++++- ...ributedApplicationTestingBuilderOptions.cs | 38 ++++ src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + .../Backchannel/DashboardUrlsHelper.cs | 41 +++- .../DistributedApplicationBuilder.cs | 8 +- .../DistributedApplicationOptions.cs | 6 + .../DashboardTestingBuilderTests.cs | 196 ++++++++++++++++++ .../DashboardUrlTests.cs | 172 +++++++++++++++ 10 files changed, 728 insertions(+), 22 deletions(-) create mode 100644 src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs create mode 100644 tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs create mode 100644 tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs diff --git a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj index 132cfa53dbc..ac1023a4328 100644 --- a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj +++ b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj @@ -11,13 +11,6 @@ false - - - - - - - diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs index 739f7af180b..09706f8ee9a 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Backchannel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Testing; @@ -12,6 +14,92 @@ namespace Aspire.Hosting.Testing; /// public static class DistributedApplicationHostingTestingExtensions { + private const string DashboardDisabledExceptionMessage = "The dashboard is not enabled for this application."; + private const string DashboardUrlApplicationNotStartedExceptionMessage = "The application must be started before retrieving the dashboard URL."; + private const string DashboardUrlPublishModeExceptionMessage = "The dashboard URL is not available in publish mode."; + private const string DashboardUrlUnavailableExceptionMessage = "The dashboard URL is not available."; + + /// + /// Gets the URL for the running Aspire dashboard. + /// + /// The distributed application. + /// A token used to cancel the operation. + /// + /// An absolute for the dashboard. When browser token authentication is enabled, the URI includes + /// the login credential. + /// + /// + /// + /// Enable dashboard support with when creating + /// the testing builder. + /// + /// + /// This method does not start the distributed application. Call + /// before requesting the dashboard URL. + /// + /// + /// The returned URI may contain an authentication credential. Treat it as a secret and do not log or share it. + /// + /// + /// Thrown when is . + /// + /// Thrown when the application is in publish mode, the dashboard is disabled, the application has not started, + /// or a dashboard URL is unavailable. + /// + /// Thrown when the dashboard reaches a terminal failure state. + /// Thrown when is canceled. + /// Thrown when the distributed application has been disposed. + /// + /// + /// var options = new DistributedApplicationTestingBuilderOptions + /// { + /// EnableDashboard = true + /// }; + /// + /// var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.MyAppHost_AppHost>(options, []); + /// await using var app = await builder.BuildAsync(); + /// await app.StartAsync(); + /// + /// var dashboardUrl = await app.GetDashboardUrlAsync(); + /// + /// + [AspireExportIgnore(Reason = "Dashboard URLs are only available to .NET test code and may contain authentication credentials.")] + public static async Task GetDashboardUrlAsync( + this DistributedApplication app, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(app); + + var executionContext = app.Services.GetRequiredService(); + if (executionContext.IsPublishMode) + { + throw new InvalidOperationException(DashboardUrlPublishModeExceptionMessage); + } + + var applicationOptions = app.Services.GetRequiredService(); + if (applicationOptions.DisableDashboard) + { + throw new InvalidOperationException(DashboardDisabledExceptionMessage); + } + + ThrowIfNotStarted(app, DashboardUrlApplicationNotStartedExceptionMessage); + cancellationToken.ThrowIfCancellationRequested(); + + var logger = app.Services.GetRequiredService().CreateLogger( + "Aspire.Hosting.Testing.DistributedApplicationDashboard"); + var dashboardUrl = await DashboardUrlsHelper.GetDashboardUrlOrThrowAsync( + app.Services, + logger, + cancellationToken).ConfigureAwait(false); + + if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri)) + { + throw new InvalidOperationException(DashboardUrlUnavailableExceptionMessage); + } + + return dashboardUri; + } + /// /// Creates an configured to communicate with the specified resource. /// @@ -127,7 +215,7 @@ internal static Uri GetEndpointForNetworkExport(this DistributedApplication app, static IResource GetResource(DistributedApplication app, string resourceName) { - ThrowIfNotStarted(app); + ThrowIfNotStarted(app, Properties.Resources.ApplicationNotStartedExceptionMessage); var applicationModel = app.Services.GetRequiredService(); if (!applicationModel.Resources.TryGetByName(resourceName, out var resource)) @@ -166,12 +254,12 @@ static string GetEndpointUriStringCore(DistributedApplication app, string resour return endpoint.Url; } - static void ThrowIfNotStarted(DistributedApplication app) + static void ThrowIfNotStarted(DistributedApplication app, string exceptionMessage) { var lifetime = app.Services.GetRequiredService(); if (!lifetime.ApplicationStarted.IsCancellationRequested) { - throw new InvalidOperationException(Properties.Resources.ApplicationNotStartedExceptionMessage); + throw new InvalidOperationException(exceptionMessage); } } diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 21ac4d57fab..a9ebfd03c96 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -22,6 +22,8 @@ namespace Aspire.Hosting.Testing; /// public static class DistributedApplicationTestingBuilder { + private const string DashboardTestingPublishModeExceptionMessage = "Dashboard testing is not supported in publish mode."; + /// /// Creates a new instance of . /// @@ -37,6 +39,39 @@ public static Task CreateAsync CreateAsync(typeof(TEntryPoint), cancellationToken); + /// + /// Creates a new instance of using the specified testing options. + /// + /// + /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used. + /// + /// The options that configure behavior selected while the underlying builder is constructed. + /// The command line arguments to pass to the entry point. + /// The . + /// + /// A new instance of . + /// + /// Thrown when or is . + /// Thrown when contains a or empty value. + /// + /// Thrown when is enabled in publish mode. + /// + /// + /// The parameter is required so calls such as CreateAsync<TEntryPoint>(default) + /// continue to bind to the existing cancellation-token overload. + /// + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] + public static Task CreateAsync( + DistributedApplicationTestingBuilderOptions options, + string[] args, + CancellationToken cancellationToken = default) + where TEntryPoint : class + { + ArgumentNullException.ThrowIfNull(options); + + return CreateAsync(typeof(TEntryPoint), options, args, cancellationToken); + } + /// /// Creates a new instance of . /// @@ -49,6 +84,39 @@ public static Task CreateAsync CreateAsync(Type entryPoint, CancellationToken cancellationToken = default) => CreateAsync(entryPoint, [], cancellationToken); + /// + /// Creates a new instance of using the specified testing options. + /// + /// A type in the entry point assembly of the target Aspire AppHost. Typically, the Program class can be used. + /// The options that configure behavior selected while the underlying builder is constructed. + /// The command line arguments to pass to the entry point. + /// The . + /// + /// A new instance of . + /// + /// + /// Thrown when , , or is . + /// + /// Thrown when contains a or empty value. + /// + /// Thrown when is enabled in publish mode. + /// + /// + /// The parameter is required so calls such as CreateAsync(entryPoint, default) + /// continue to bind to the existing cancellation-token overload. + /// + [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] + public static Task CreateAsync( + Type entryPoint, + DistributedApplicationTestingBuilderOptions options, + string[] args, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + + return CreateAsyncCore(entryPoint, args, options.EnableDashboard, (_, __) => { }, cancellationToken); + } + /// /// Creates a new instance of . /// @@ -106,12 +174,20 @@ public static Task CreateAsync [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] public static async Task CreateAsync(Type entryPoint, string[] args, Action configureBuilder, CancellationToken cancellationToken = default) + => await CreateAsyncCore(entryPoint, args, enableDashboard: false, configureBuilder, cancellationToken).ConfigureAwait(false); + + private static async Task CreateAsyncCore( + Type entryPoint, + string[] args, + bool enableDashboard, + Action configureBuilder, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(entryPoint); ThrowIfNullOrContainsIsNullOrEmpty(args); ArgumentNullException.ThrowIfNull(configureBuilder, nameof(configureBuilder)); - var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, configureBuilder); + var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, enableDashboard, configureBuilder); return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); } @@ -125,6 +201,28 @@ public static async Task CreateAsync(Type public static IDistributedApplicationTestingBuilder Create(params string[] args) => Create(args, (_, __) => { }); + /// + /// Creates a new instance of using the specified testing options. + /// + /// The options that configure behavior selected while the underlying builder is constructed. + /// The command line arguments to use when building the distributed application. + /// + /// A new instance of . + /// + /// Thrown when or is . + /// Thrown when contains a or empty value. + /// + /// Thrown when is enabled in publish mode. + /// + public static IDistributedApplicationTestingBuilder Create( + DistributedApplicationTestingBuilderOptions options, + string[] args) + { + ArgumentNullException.ThrowIfNull(options); + + return CreateCore(args, options.EnableDashboard, (_, __) => { }); + } + /// /// Creates a new instance of . /// @@ -134,11 +232,18 @@ public static IDistributedApplicationTestingBuilder Create(params string[] args) /// A new instance of . /// public static IDistributedApplicationTestingBuilder Create(string[] args, Action configureBuilder) + => CreateCore(args, enableDashboard: false, configureBuilder); + + private static IDistributedApplicationTestingBuilder CreateCore( + string[] args, + bool enableDashboard, + Action configureBuilder, + Assembly? appHostAssembly = null) { ThrowIfNullOrContainsIsNullOrEmpty(args); ArgumentNullException.ThrowIfNull(configureBuilder); - return new TestingBuilder(args, configureBuilder); + return new TestingBuilder(args, enableDashboard, configureBuilder, appHostAssembly); } /// @@ -154,11 +259,63 @@ internal static IDistributedApplicationTestingBuilder Create( string[] args, Action configureBuilder, Assembly appHostAssembly) + => CreateCore(args, enableDashboard: false, configureBuilder, appHostAssembly); + + private static void ConfigureDashboardTesting( + DistributedApplicationOptions applicationOptions, + HostApplicationBuilderSettings hostBuilderOptions, + bool enableDashboard) { - ThrowIfNullOrContainsIsNullOrEmpty(args); - ArgumentNullException.ThrowIfNull(configureBuilder); + if (!enableDashboard) + { + return; + } - return new TestingBuilder(args, configureBuilder, appHostAssembly); + applicationOptions.DisableDashboard = false; + applicationOptions.DashboardUnsecuredAllowAnonymous = false; + + hostBuilderOptions.Configuration ??= new(); + AddDashboardTestingConfiguration(hostBuilderOptions.Configuration); + } + + private static void ConfigureDashboardTesting(IDistributedApplicationBuilder builder, bool enableDashboard) + { + if (!enableDashboard) + { + return; + } + + if (builder.ExecutionContext.IsPublishMode) + { + throw new InvalidOperationException(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); + + // Enabling the dashboard changes the hosting default to wait indefinitely when a dependency becomes + // unavailable. Tests must retain the testing builder's fail-fast behavior, while a later user + // registration can still override this default. + builder.Services.Configure( + options => options.DefaultWaitBehavior = WaitBehavior.StopOnResourceUnavailable); + } + + private static void AddDashboardTestingConfiguration(IConfigurationBuilder configuration) + { + configuration.AddInMemoryCollection(new Dictionary + { + ["DcpPublisher:RandomizePorts"] = "true", + [KnownAspNetCoreConfigNames.Urls] = "http://127.0.0.1:0", + [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://127.0.0.1:0", + [KnownConfigNames.DashboardOtlpHttpEndpointUrl] = "http://127.0.0.1:0", + [KnownConfigNames.ResourceServiceEndpointUrl] = "http://127.0.0.1:0", + [KnownConfigNames.AllowUnsecuredTransport] = "true", + [KnownConfigNames.DashboardUnsecuredAllowAnonymous] = "false", + [KnownConfigNames.InteractivityEnabled] = "false" + }); } private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) @@ -178,7 +335,11 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) } } - private sealed class SuspendingDistributedApplicationFactory(Type entryPoint, string[] args, Action configureBuilder) + private sealed class SuspendingDistributedApplicationFactory( + Type entryPoint, + string[] args, + bool enableDashboard, + Action configureBuilder) : DistributedApplicationFactory(entryPoint, args) { private readonly SemaphoreSlim _continueBuilding = new(0); @@ -192,12 +353,14 @@ public async Task CreateBuilderAsync(Canc protected override void OnBuilderCreating(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostOptions) { base.OnBuilderCreating(applicationOptions, hostOptions); + ConfigureDashboardTesting(applicationOptions, hostOptions, enableDashboard); configureBuilder(applicationOptions, hostOptions); } protected override void OnBuilderCreated(DistributedApplicationBuilder applicationBuilder) { base.OnBuilderCreated(applicationBuilder); + ConfigureDashboardTesting(applicationBuilder, enableDashboard); } protected override void OnBuilding(DistributedApplicationBuilder applicationBuilder) @@ -323,15 +486,17 @@ public async Task StopAsync(CancellationToken cancellationToken) private sealed class TestingBuilder( string[] args, + bool enableDashboard, Action configureBuilder, Assembly? appHostAssembly = null) : IDistributedApplicationTestingBuilder { - private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, configureBuilder, appHostAssembly); + private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, enableDashboard, configureBuilder, appHostAssembly); private DistributedApplication? _app; private static DistributedApplicationBuilder CreateInnerBuilder( string[] args, + bool enableDashboard, Action configureBuilder, Assembly? appHostAssembly = null) { @@ -347,9 +512,15 @@ private static DistributedApplicationBuilder CreateInnerBuilder( appAssembly = FindApplicationAssembly(); } - DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, configureBuilder); + DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, (options, settings) => + { + ConfigureDashboardTesting(options, settings, enableDashboard); + configureBuilder(options, settings); + }); }); + ConfigureDashboardTesting(builder, enableDashboard); + if (!builder.Configuration.GetValue(KnownConfigNames.TestingDisableHttpClient, false)) { builder.Services.AddHttpClient(); diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs new file mode 100644 index 00000000000..9451c441097 --- /dev/null +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.Testing; + +/// +/// Provides options for creating an . +/// +/// +/// Dashboard support is configured while the underlying distributed application builder is constructed because +/// the dashboard services cannot be added after construction. When enabled, the testing builder uses authenticated +/// HTTP endpoints on randomized loopback ports, disables interactivity, and retains the testing builder's fail-fast +/// dependency behavior. Configuration applied to the returned builder can override runtime settings such as port +/// allocation, interactivity, and dependency waiting before the application is built. Dashboard authentication is +/// selected while the underlying builder is constructed. +/// +/// +/// The following example creates a testing builder with dashboard support: +/// +/// var options = new DistributedApplicationTestingBuilderOptions +/// { +/// EnableDashboard = true +/// }; +/// +/// var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.MyAppHost_AppHost>(options, []); +/// +/// +public sealed class DistributedApplicationTestingBuilderOptions +{ + /// + /// Gets or sets a value indicating whether the Aspire dashboard is enabled for the testing application. + /// + /// + /// to enable the dashboard; otherwise, . + /// The default is . + /// + public bool EnableDashboard { get; set; } +} diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 6f674b02705..f257eba7b61 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -137,6 +137,7 @@ + diff --git a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs index 9522d2d1fa1..59f5463ca0d 100644 --- a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs +++ b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs @@ -17,7 +17,6 @@ namespace Aspire.Hosting.Backchannel; /// internal static class DashboardUrlsHelper { - /// /// Gets all dashboard connection information in a single call. /// Waits for the dashboard to become healthy before returning. @@ -30,6 +29,40 @@ public static async Task GetDashboardConnectionInfoAsyn IServiceProvider serviceProvider, ILogger logger, CancellationToken cancellationToken = default) + { + return await GetDashboardConnectionInfoCoreAsync( + serviceProvider, + logger, + throwOnDashboardFailure: false, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets the authenticated dashboard URL and preserves terminal dashboard failures. + /// + /// The service provider. + /// The logger for diagnostic output. + /// A cancellation token. + /// The authenticated dashboard URL, or when no URL is available. + internal static async Task GetDashboardUrlOrThrowAsync( + IServiceProvider serviceProvider, + ILogger logger, + CancellationToken cancellationToken) + { + var info = await GetDashboardConnectionInfoCoreAsync( + serviceProvider, + logger, + throwOnDashboardFailure: true, + cancellationToken).ConfigureAwait(false); + + return info.BaseUrlWithLoginToken; + } + + private static async Task GetDashboardConnectionInfoCoreAsync( + IServiceProvider serviceProvider, + ILogger logger, + bool throwOnDashboardFailure, + CancellationToken cancellationToken) { // Check whether the dashboard resource is registered at all. If it isn't (e.g. the user // called DisableDashboard()), return immediately rather than waiting forever for a resource @@ -67,6 +100,12 @@ await resourceNotificationService.WaitForResourceHealthyAsync( waitActivity.SetError(ex); activity.SetDashboardHealthy(false); logger.LogWarning(ex, "An error occurred while waiting for the Aspire Dashboard to become healthy."); + + if (throwOnDashboardFailure) + { + throw; + } + return DashboardConnectionInfo.Unhealthy; } } diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 7f1ddfa98af..9464ea75fd7 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -435,7 +435,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // Dashboard if (!options.DisableDashboard) { - if (!IsDashboardUnsecured(_innerBuilder.Configuration)) + if (!IsDashboardUnsecured(options, _innerBuilder.Configuration)) { // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. // Set a random API key for the OTLP exporter if one isn't already present in configuration. @@ -752,9 +752,11 @@ private void MapTransportOptionsFromCustomKeys(TransportOptions options) } } - private static bool IsDashboardUnsecured(IConfiguration configuration) + private static bool IsDashboardUnsecured(DistributedApplicationOptions options, IConfiguration configuration) { - return configuration.GetBool(KnownConfigNames.DashboardUnsecuredAllowAnonymous, KnownConfigNames.Legacy.DashboardUnsecuredAllowAnonymous) ?? false; + return options.DashboardUnsecuredAllowAnonymous + ?? configuration.GetBool(KnownConfigNames.DashboardUnsecuredAllowAnonymous, KnownConfigNames.Legacy.DashboardUnsecuredAllowAnonymous) + ?? false; } private void ConfigurePipelineOptions(DistributedApplicationOptions options) diff --git a/src/Aspire.Hosting/DistributedApplicationOptions.cs b/src/Aspire.Hosting/DistributedApplicationOptions.cs index e86934d7dc9..215c6402810 100644 --- a/src/Aspire.Hosting/DistributedApplicationOptions.cs +++ b/src/Aspire.Hosting/DistributedApplicationOptions.cs @@ -62,6 +62,12 @@ public DistributedApplicationOptions() /// public bool DisableDashboard { get; set; } + /// + /// Gets or sets whether anonymous dashboard access is enabled while the builder is constructed. + /// A value uses configuration. + /// + internal bool? DashboardUnsecuredAllowAnonymous { get; set; } + internal Assembly? Assembly => _assembly.Value; internal string? Configuration => _configurationLazy.Value; diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs new file mode 100644 index 00000000000..537a2f19957 --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -0,0 +1,196 @@ +// 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 ASPIREINTERACTION001 + +using Aspire.Hosting.Dashboard; +using Aspire.Hosting.Dcp; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Aspire.Hosting.Testing.Tests; + +public class DashboardTestingBuilderTests +{ + private const string AspNetCoreUrls = "ASPNETCORE_URLS"; + private const string DashboardOtlpGrpcEndpointUrl = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"; + private const string DashboardOtlpHttpEndpointUrl = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"; + private const string DashboardUnsecuredAllowAnonymous = "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"; + private const string InteractivityEnabled = "ASPIRE_INTERACTIVITY_ENABLED"; + private const string ResourceServiceEndpointUrl = "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"; + + [Fact] + public void DashboardIsDisabledByDefault() + { + var options = new DistributedApplicationTestingBuilderOptions(); + + Assert.False(options.EnableDashboard); + + using var builder = DistributedApplicationTestingBuilder.Create(); + Assert.Null(builder.Services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DashboardServiceHost))); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardCanBeEnabledAtBuilderCreation(CreationSurface creationSurface) + { + var builder = await CreateDashboardBuilderAsync(creationSurface, []); + + Assert.Single(builder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); + Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[AspNetCoreUrls]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpGrpcEndpointUrl]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpHttpEndpointUrl]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); + Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); + Assert.Equal("false", builder.Configuration[InteractivityEnabled]); + + await using var app = await builder.BuildAsync(); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingDefaultsOverrideConfigurationPresentDuringCreation(CreationSurface creationSurface) + { + string[] args = + [ + "--DcpPublisher:RandomizePorts=false", + $"--{AspNetCoreUrls}=http://127.0.0.1:12345", + $"--{DashboardOtlpGrpcEndpointUrl}=http://127.0.0.1:12346", + $"--{DashboardOtlpHttpEndpointUrl}=http://127.0.0.1:12347", + $"--{ResourceServiceEndpointUrl}=http://127.0.0.1:12348", + $"--{DashboardUnsecuredAllowAnonymous}=true", + $"--{InteractivityEnabled}=true" + ]; + + await using var builder = await CreateDashboardBuilderAsync(creationSurface, args); + + Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[AspNetCoreUrls]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpGrpcEndpointUrl]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpHttpEndpointUrl]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); + Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); + Assert.Equal("false", builder.Configuration[InteractivityEnabled]); + Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration["AppHost:ResourceService:AuthMode"]); + } + + [Fact] + public async Task DashboardTestingDefaultsAreNonInteractiveAndFailFast() + { + var builder = DistributedApplicationTestingBuilder.Create(CreateDashboardOptions(), []); + + await using var app = await builder.BuildAsync(); + + Assert.False(app.Services.GetRequiredService().IsAvailable); + Assert.Equal( + WaitBehavior.StopOnResourceUnavailable, + app.Services.GetRequiredService>().Value.DefaultWaitBehavior); + } + + [Fact] + public async Task DashboardTestingDefaultsCanBeOverriddenAfterCreation() + { + var builder = DistributedApplicationTestingBuilder.Create(CreateDashboardOptions(), []); + builder.Configuration[InteractivityEnabled] = "true"; + builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; + builder.Configuration[AspNetCoreUrls] = "http://127.0.0.1:12345"; + builder.Services.Configure( + options => options.DefaultWaitBehavior = WaitBehavior.WaitOnResourceUnavailable); + + await using var app = await builder.BuildAsync(); + + Assert.True(app.Services.GetRequiredService().IsAvailable); + Assert.False(app.Services.GetRequiredService>().Value.RandomizePorts); + Assert.Equal( + "http://127.0.0.1:12345", + app.Services.GetRequiredService>().Value.DashboardUrl); + Assert.Equal( + WaitBehavior.WaitOnResourceUnavailable, + app.Services.GetRequiredService>().Value.DefaultWaitBehavior); + } + + [Fact] + public async Task DashboardTestingOptionsCannotBeNull() + { + await Assert.ThrowsAsync( + () => DistributedApplicationTestingBuilder.CreateAsync(null!, [])); + await Assert.ThrowsAsync( + () => DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), null!, [])); + Assert.Throws(() => DistributedApplicationTestingBuilder.Create(null!, [])); + } + + [Fact] + public async Task ExistingDefaultCallsRemainUnambiguous() + { + Assert.Throws(() => DistributedApplicationTestingBuilder.Create(default!)); + + await using var genericBuilder = + await DistributedApplicationTestingBuilder.CreateAsync(default); + await using var typeBuilder = + await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), default); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingIsRejectedInPublishMode(CreationSurface creationSurface) + { + var exception = await Assert.ThrowsAsync( + () => CreatePublishBuilderAsync(creationSurface)); + + Assert.Equal("Dashboard testing is not supported in publish mode.", exception.Message); + } + + private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() + { + return new() + { + EnableDashboard = true + }; + } + + private static async Task CreateDashboardBuilderAsync( + CreationSurface creationSurface, + string[] args) + { + var options = CreateDashboardOptions(); + + return creationSurface switch + { + CreationSurface.Generic => await DistributedApplicationTestingBuilder.CreateAsync(options, args), + CreationSurface.Type => await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), options, args), + CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, args), + _ => throw new ArgumentOutOfRangeException(nameof(creationSurface)) + }; + } + + private static async Task CreatePublishBuilderAsync(CreationSurface creationSurface) + { + var options = CreateDashboardOptions(); + string[] args = ["--publisher", "manifest"]; + + var builder = creationSurface switch + { + CreationSurface.Generic => await DistributedApplicationTestingBuilder.CreateAsync(options, args), + CreationSurface.Type => await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), options, args), + CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, args), + _ => throw new ArgumentOutOfRangeException(nameof(creationSurface)) + }; + + await builder.DisposeAsync(); + } + + public enum CreationSurface + { + Generic, + Type, + AdHoc + } +} diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs new file mode 100644 index 00000000000..c805f9fa945 --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TestUtilities; +using Microsoft.AspNetCore.InternalTesting; +using Xunit; + +namespace Aspire.Hosting.Testing.Tests; + +public class DashboardUrlTests +{ + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardUrlAsyncReturnsReachableAuthenticatedDynamicLoopbackUrl() + { + var builder = CreateDashboardBuilder(); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.True(dashboardUri.IsAbsoluteUri); + Assert.Equal(Uri.UriSchemeHttp, dashboardUri.Scheme); + Assert.True(dashboardUri.IsLoopback); + Assert.InRange(dashboardUri.Port, 1, 65535); + Assert.Equal("/login", dashboardUri.AbsolutePath); + Assert.StartsWith("?t=", dashboardUri.Query, StringComparison.Ordinal); + Assert.True(dashboardUri.Query.Length > 3); + + using var httpClient = new HttpClient + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var response = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + Assert.True(response.IsSuccessStatusCode, $"The dashboard returned HTTP status code {(int)response.StatusCode}."); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task ConcurrentApplicationsUseDifferentDashboardUrls() + { + var firstBuilder = CreateDashboardBuilder(); + var secondBuilder = CreateDashboardBuilder(); + await using var firstApp = await firstBuilder.BuildAsync(); + await using var secondApp = await secondBuilder.BuildAsync(); + + await Task.WhenAll(firstApp.StartAsync(), secondApp.StartAsync()).WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var urls = await Task.WhenAll( + firstApp.GetDashboardUrlAsync(cancellationTokenSource.Token), + secondApp.GetDashboardUrlAsync(cancellationTokenSource.Token)); + + Assert.Equal(Uri.UriSchemeHttp, urls[0].Scheme); + Assert.Equal(Uri.UriSchemeHttp, urls[1].Scheme); + Assert.NotEqual(urls[0].Port, urls[1].Port); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardUrlAsyncThrowsWhenDashboardIsDisabled() + { + var builder = DistributedApplicationTestingBuilder.Create(); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + + Assert.Equal("The dashboard is not enabled for this application.", exception.Message); + } + + [Fact] + public async Task GetDashboardUrlAsyncThrowsBeforeApplicationStarts() + { + var builder = CreateDashboardBuilder(); + await using var app = await builder.BuildAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + + Assert.Equal("The application must be started before retrieving the dashboard URL.", exception.Message); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardUrlAsyncHonorsCancellation() + { + var builder = CreateDashboardBuilder(); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync( + () => app.GetDashboardUrlAsync(cancellationTokenSource.Token)); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardUrlAsyncThrowsAfterApplicationIsDisposed() + { + var builder = CreateDashboardBuilder(); + var app = await builder.BuildAsync(); + + try + { + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + _ = await app.GetDashboardUrlAsync(default); + await app.DisposeAsync(); + + await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + } + finally + { + await app.DisposeAsync(); + } + } + + [Fact] + public async Task GetDashboardUrlAsyncThrowsInPublishMode() + { + var builder = DistributedApplicationTestingBuilder.Create( + ["--publisher", "manifest"], + (options, _) => options.DisableDashboard = false); + await using var app = await builder.BuildAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(default)); + + Assert.Equal("The dashboard URL is not available in publish mode.", exception.Message); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardUrlAsyncPreservesTerminalDashboardFailure() + { + var missingDashboardPath = Path.Combine( + AppContext.BaseDirectory, + "missing-dashboard", + Guid.NewGuid().ToString("N")); + var builder = DistributedApplicationTestingBuilder.Create( + CreateDashboardOptions(), + [$"DcpPublisher:DashboardPath={missingDashboardPath}"]); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardUrlAsync(cancellationTokenSource.Token)); + + Assert.Equal( + "Stopped waiting for resource 'aspire-dashboard' to become healthy because it failed to start.", + exception.Message); + } + + private static IDistributedApplicationTestingBuilder CreateDashboardBuilder() + { + return DistributedApplicationTestingBuilder.Create(CreateDashboardOptions(), []); + } + + private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() + { + return new() + { + EnableDashboard = true + }; + } +} From 8ef74cda345c38993f73a223b337e7bfd7dd6d74 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 06:16:36 -0400 Subject: [PATCH 02/21] Finish testing dashboard support Reuse the canonical dashboard URL path, enforce authenticated testing defaults, and preserve cancellation and disposal semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffeff87e-f284-434d-87d3-843e21a7aebb --- ...utedApplicationHostingTestingExtensions.cs | 39 +- .../DistributedApplicationTestingBuilder.cs | 114 +++++- ...ributedApplicationTestingBuilderOptions.cs | 7 +- .../Backchannel/DashboardUrlsHelper.cs | 14 +- .../DistributedApplicationBuilder.cs | 8 +- .../DistributedApplicationOptions.cs | 6 - .../DashboardLoginUrlTests.cs | 352 ++++++++++++++++++ .../DashboardTestingBuilderTests.cs | 116 +++++- .../DashboardUrlTests.cs | 172 --------- .../TestingAppHost1.AppHost/Program.cs | 19 + .../TestingAppHostBuildProbe.cs | 133 +++++++ 11 files changed, 744 insertions(+), 236 deletions(-) create mode 100644 tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs delete mode 100644 tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs create mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs index 09706f8ee9a..59dd8c1e2bf 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs @@ -15,18 +15,18 @@ namespace Aspire.Hosting.Testing; public static class DistributedApplicationHostingTestingExtensions { private const string DashboardDisabledExceptionMessage = "The dashboard is not enabled for this application."; - private const string DashboardUrlApplicationNotStartedExceptionMessage = "The application must be started before retrieving the dashboard URL."; - private const string DashboardUrlPublishModeExceptionMessage = "The dashboard URL is not available in publish mode."; - private const string DashboardUrlUnavailableExceptionMessage = "The dashboard URL is not available."; + private const string DashboardLoginUrlAnonymousExceptionMessage = "The dashboard login URL is not available because anonymous dashboard access is enabled."; + private const string DashboardLoginUrlApplicationNotStartedExceptionMessage = "The application must be started before retrieving the dashboard login URL."; + private const string DashboardLoginUrlPublishModeExceptionMessage = "The dashboard login URL is not available in publish mode."; + private const string DashboardLoginUrlUnavailableExceptionMessage = "The dashboard login URL is not available."; /// - /// Gets the URL for the running Aspire dashboard. + /// Gets the authenticated login URL for the running Aspire dashboard. /// /// The distributed application. /// A token used to cancel the operation. /// - /// An absolute for the dashboard. When browser token authentication is enabled, the URI includes - /// the login credential. + /// An absolute that authenticates the browser with the running dashboard. /// /// /// @@ -35,16 +35,17 @@ public static class DistributedApplicationHostingTestingExtensions /// /// /// This method does not start the distributed application. Call - /// before requesting the dashboard URL. + /// before requesting the dashboard login URL. /// /// - /// The returned URI may contain an authentication credential. Treat it as a secret and do not log or share it. + /// This method waits for the dashboard resource to become healthy. Pass a cancellation token when the wait must + /// be bounded. The returned URI contains an authentication credential and should be treated as sensitive. /// /// /// Thrown when is . /// /// Thrown when the application is in publish mode, the dashboard is disabled, the application has not started, - /// or a dashboard URL is unavailable. + /// anonymous dashboard access is enabled, or a dashboard login URL is unavailable. /// /// Thrown when the dashboard reaches a terminal failure state. /// Thrown when is canceled. @@ -60,11 +61,11 @@ public static class DistributedApplicationHostingTestingExtensions /// await using var app = await builder.BuildAsync(); /// await app.StartAsync(); /// - /// var dashboardUrl = await app.GetDashboardUrlAsync(); + /// var dashboardLoginUrl = await app.GetDashboardLoginUrlAsync(); /// /// [AspireExportIgnore(Reason = "Dashboard URLs are only available to .NET test code and may contain authentication credentials.")] - public static async Task GetDashboardUrlAsync( + public static async Task GetDashboardLoginUrlAsync( this DistributedApplication app, CancellationToken cancellationToken = default) { @@ -73,7 +74,7 @@ public static async Task GetDashboardUrlAsync( var executionContext = app.Services.GetRequiredService(); if (executionContext.IsPublishMode) { - throw new InvalidOperationException(DashboardUrlPublishModeExceptionMessage); + throw new InvalidOperationException(DashboardLoginUrlPublishModeExceptionMessage); } var applicationOptions = app.Services.GetRequiredService(); @@ -82,19 +83,25 @@ public static async Task GetDashboardUrlAsync( throw new InvalidOperationException(DashboardDisabledExceptionMessage); } - ThrowIfNotStarted(app, DashboardUrlApplicationNotStartedExceptionMessage); + ThrowIfNotStarted(app, DashboardLoginUrlApplicationNotStartedExceptionMessage); cancellationToken.ThrowIfCancellationRequested(); var logger = app.Services.GetRequiredService().CreateLogger( "Aspire.Hosting.Testing.DistributedApplicationDashboard"); - var dashboardUrl = await DashboardUrlsHelper.GetDashboardUrlOrThrowAsync( + var dashboardInfo = await DashboardUrlsHelper.GetDashboardConnectionInfoOrThrowAsync( app.Services, logger, cancellationToken).ConfigureAwait(false); - if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri)) + var dashboardUrl = dashboardInfo.CodespacesUrlWithLoginToken ?? dashboardInfo.BaseUrlWithLoginToken; + if (!dashboardInfo.IsHealthy || !Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri)) { - throw new InvalidOperationException(DashboardUrlUnavailableExceptionMessage); + throw new InvalidOperationException(DashboardLoginUrlUnavailableExceptionMessage); + } + + if (!dashboardInfo.HasBrowserToken) + { + throw new InvalidOperationException(DashboardLoginUrlAnonymousExceptionMessage); } return dashboardUri; diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index a9ebfd03c96..2c64b893829 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -51,8 +51,11 @@ public static Task CreateAsync /// A new instance of . /// - /// Thrown when or is . - /// Thrown when contains a or empty value. + /// + /// Thrown when or is , or when + /// contains a value. + /// + /// Thrown when contains an empty value. /// /// Thrown when is enabled in publish mode. /// @@ -95,9 +98,10 @@ public static Task CreateAsync(Type entry /// A new instance of . /// /// - /// Thrown when , , or is . + /// Thrown when , , or is + /// , or when contains a value. /// - /// Thrown when contains a or empty value. + /// Thrown when contains an empty value. /// /// Thrown when is enabled in publish mode. /// @@ -188,7 +192,15 @@ private static async Task CreateAsyncCore ArgumentNullException.ThrowIfNull(configureBuilder, nameof(configureBuilder)); var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, enableDashboard, configureBuilder); - return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); + try + { + return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + await factory.DisposeAsync().ConfigureAwait(false); + throw; + } } /// @@ -209,11 +221,18 @@ public static IDistributedApplicationTestingBuilder Create(params string[] args) /// /// A new instance of . /// - /// Thrown when or is . - /// Thrown when contains a or empty value. + /// + /// Thrown when or is , or when + /// contains a value. + /// + /// Thrown when contains an empty value. /// /// Thrown when is enabled in publish mode. /// + /// + /// The parameter is required so calls such as Create(default) continue to bind to + /// the existing command-line-arguments overload. + /// public static IDistributedApplicationTestingBuilder Create( DistributedApplicationTestingBuilderOptions options, string[] args) @@ -272,7 +291,16 @@ private static void ConfigureDashboardTesting( } applicationOptions.DisableDashboard = false; - applicationOptions.DashboardUnsecuredAllowAnonymous = 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. + hostBuilderOptions.Args = + [ + .. (hostBuilderOptions.Args ?? applicationOptions.Args ?? []), + $"{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=false" + ]; + applicationOptions.Args = hostBuilderOptions.Args; hostBuilderOptions.Configuration ??= new(); AddDashboardTestingConfiguration(hostBuilderOptions.Configuration); @@ -343,24 +371,20 @@ private sealed class SuspendingDistributedApplicationFactory( : DistributedApplicationFactory(entryPoint, args) { private readonly SemaphoreSlim _continueBuilding = new(0); + private int _buildingContinuationState; public async Task CreateBuilderAsync(CancellationToken cancellationToken) { var innerBuilder = await ResolveBuilderAsync(cancellationToken).ConfigureAwait(false); + ConfigureDashboardTesting(innerBuilder, enableDashboard); return new Builder(this, innerBuilder); } protected override void OnBuilderCreating(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostOptions) { base.OnBuilderCreating(applicationOptions, hostOptions); - ConfigureDashboardTesting(applicationOptions, hostOptions, enableDashboard); configureBuilder(applicationOptions, hostOptions); - } - - protected override void OnBuilderCreated(DistributedApplicationBuilder applicationBuilder) - { - base.OnBuilderCreated(applicationBuilder); - ConfigureDashboardTesting(applicationBuilder, enableDashboard); + ConfigureDashboardTesting(applicationOptions, hostOptions, enableDashboard); } protected override void OnBuilding(DistributedApplicationBuilder applicationBuilder) @@ -369,26 +393,76 @@ protected override void OnBuilding(DistributedApplicationBuilder applicationBuil // Wait until the owner signals that building can continue by calling BuildAsync(). _continueBuilding.Wait(); + if (Volatile.Read(ref _buildingContinuationState) == 2) + { + throw new ObjectDisposedException( + nameof(IDistributedApplicationTestingBuilder), + "The testing builder was disposed before the application was built."); + } } public async Task BuildAsync(CancellationToken cancellationToken) { - _continueBuilding.Release(); - return await ResolveApplicationAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var previousState = Interlocked.CompareExchange(ref _buildingContinuationState, 1, 0); + ObjectDisposedException.ThrowIf(previousState == 2, this); + if (previousState == 0) + { + _continueBuilding.Release(); + } + + try + { + return await ResolveApplicationAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Once the AppHost has been released, it can still finish building after the caller stops waiting. + // Wait for that application and dispose the factory before propagating cancellation so it cannot + // continue running without an owner. + try + { + _ = await ResolveApplicationAsync(CancellationToken.None).ConfigureAwait(false); + } + finally + { + await DisposeAsync().ConfigureAwait(false); + } + + throw; + } + catch (OperationCanceledException) when (Volatile.Read(ref _buildingContinuationState) == 2) + { + throw new ObjectDisposedException( + nameof(IDistributedApplicationTestingBuilder), + "The testing builder was disposed before the application was built."); + } } public override async ValueTask DisposeAsync() { - _continueBuilding.Release(); + PrepareForDisposal(); await base.DisposeAsync().ConfigureAwait(false); } public override void Dispose() { - _continueBuilding.Release(); + PrepareForDisposal(); base.Dispose(); } + private void PrepareForDisposal() + { + var previousState = Interlocked.Exchange(ref _buildingContinuationState, 2); + if (previousState == 0) + { + // Abort on the AppHost entry-point thread instead of allowing a rejected or canceled + // builder to continue into Build() after the factory has already been disposed. + _continueBuilding.Release(); + } + } + private sealed class Builder(SuspendingDistributedApplicationFactory factory, DistributedApplicationBuilder innerBuilder) : IDistributedApplicationTestingBuilder { public ConfigurationManager Configuration => innerBuilder.Configuration; @@ -514,8 +588,8 @@ private static DistributedApplicationBuilder CreateInnerBuilder( DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, (options, settings) => { - ConfigureDashboardTesting(options, settings, enableDashboard); configureBuilder(options, settings); + ConfigureDashboardTesting(options, settings, enableDashboard); }); }); diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs index 9451c441097..9067f6ee713 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs @@ -10,9 +10,10 @@ namespace Aspire.Hosting.Testing; /// Dashboard support is configured while the underlying distributed application builder is constructed because /// the dashboard services cannot be added after construction. When enabled, the testing builder uses authenticated /// HTTP endpoints on randomized loopback ports, disables interactivity, and retains the testing builder's fail-fast -/// dependency behavior. Configuration applied to the returned builder can override runtime settings such as port -/// allocation, interactivity, and dependency waiting before the application is built. Dashboard authentication is -/// selected while the underlying builder is constructed. +/// dependency behavior. These testing defaults are reapplied after the AppHost has configured its builder. +/// Configuration applied directly to the returned builder can override runtime settings such as port allocation, +/// interactivity, and dependency waiting before the application is built. Dashboard authentication is selected while +/// the underlying builder is constructed. /// /// /// The following example creates a testing builder with dashboard support: diff --git a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs index 59f5463ca0d..74d36cb2ee0 100644 --- a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs +++ b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs @@ -38,24 +38,22 @@ public static async Task GetDashboardConnectionInfoAsyn } /// - /// Gets the authenticated dashboard URL and preserves terminal dashboard failures. + /// Gets all dashboard connection information while preserving terminal dashboard failures. /// /// The service provider. /// The logger for diagnostic output. /// A cancellation token. - /// The authenticated dashboard URL, or when no URL is available. - internal static async Task GetDashboardUrlOrThrowAsync( + /// Complete dashboard connection information. + internal static async Task GetDashboardConnectionInfoOrThrowAsync( IServiceProvider serviceProvider, ILogger logger, CancellationToken cancellationToken) { - var info = await GetDashboardConnectionInfoCoreAsync( + return await GetDashboardConnectionInfoCoreAsync( serviceProvider, logger, throwOnDashboardFailure: true, cancellationToken).ConfigureAwait(false); - - return info.BaseUrlWithLoginToken; } private static async Task GetDashboardConnectionInfoCoreAsync( @@ -165,7 +163,7 @@ await resourceNotificationService.WaitForResourceHealthyAsync( if (!string.IsNullOrEmpty(apiBaseUrl)) { baseUrlWithLoginToken = !string.IsNullOrEmpty(dashboardOptions.DashboardToken) - ? $"{apiBaseUrl.TrimEnd('/')}/login?t={dashboardOptions.DashboardToken}" + ? $"{apiBaseUrl.TrimEnd('/')}/login?t={Uri.EscapeDataString(dashboardOptions.DashboardToken)}" : apiBaseUrl; var rewrittenUrl = codespacesUrlRewriter?.RewriteUrl(baseUrlWithLoginToken); @@ -183,6 +181,7 @@ await resourceNotificationService.WaitForResourceHealthyAsync( IsHealthy = true, ApiBaseUrl = apiBaseUrl, ApiToken = dashboardOptions.ApiKey, + HasBrowserToken = !string.IsNullOrEmpty(dashboardOptions.DashboardToken), BaseUrlWithLoginToken = baseUrlWithLoginToken, CodespacesUrlWithLoginToken = codespacesUrlWithLoginToken }; @@ -227,6 +226,7 @@ internal sealed class DashboardConnectionInfo public bool IsHealthy { get; init; } public string? ApiBaseUrl { get; init; } public string? ApiToken { get; init; } + public bool HasBrowserToken { get; init; } /// /// Gets the resolved dashboard URL. /// When browser token authentication is enabled, this value includes the login token. diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 9464ea75fd7..7f1ddfa98af 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -435,7 +435,7 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options) // Dashboard if (!options.DisableDashboard) { - if (!IsDashboardUnsecured(options, _innerBuilder.Configuration)) + if (!IsDashboardUnsecured(_innerBuilder.Configuration)) { // Passed to apps as a standard OTEL attribute to include in OTLP requests and the dashboard to validate. // Set a random API key for the OTLP exporter if one isn't already present in configuration. @@ -752,11 +752,9 @@ private void MapTransportOptionsFromCustomKeys(TransportOptions options) } } - private static bool IsDashboardUnsecured(DistributedApplicationOptions options, IConfiguration configuration) + private static bool IsDashboardUnsecured(IConfiguration configuration) { - return options.DashboardUnsecuredAllowAnonymous - ?? configuration.GetBool(KnownConfigNames.DashboardUnsecuredAllowAnonymous, KnownConfigNames.Legacy.DashboardUnsecuredAllowAnonymous) - ?? false; + return configuration.GetBool(KnownConfigNames.DashboardUnsecuredAllowAnonymous, KnownConfigNames.Legacy.DashboardUnsecuredAllowAnonymous) ?? false; } private void ConfigurePipelineOptions(DistributedApplicationOptions options) diff --git a/src/Aspire.Hosting/DistributedApplicationOptions.cs b/src/Aspire.Hosting/DistributedApplicationOptions.cs index 215c6402810..e86934d7dc9 100644 --- a/src/Aspire.Hosting/DistributedApplicationOptions.cs +++ b/src/Aspire.Hosting/DistributedApplicationOptions.cs @@ -62,12 +62,6 @@ public DistributedApplicationOptions() /// public bool DisableDashboard { get; set; } - /// - /// Gets or sets whether anonymous dashboard access is enabled while the builder is constructed. - /// A value uses configuration. - /// - internal bool? DashboardUnsecuredAllowAnonymous { get; set; } - internal Assembly? Assembly => _assembly.Value; internal string? Configuration => _configurationLazy.Value; diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs new file mode 100644 index 00000000000..e732629efcf --- /dev/null +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs @@ -0,0 +1,352 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Aspire.Hosting.Backchannel; +using Aspire.Hosting.Diagnostics; +using Aspire.TestUtilities; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Aspire.Hosting.Testing.Tests; + +public class DashboardLoginUrlTests +{ + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardLoginUrlAsyncAuthenticatesDashboardBrowser() + { + await using var builder = await CreateDashboardBuilderAsync(); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + + Assert.True(dashboardUri.IsAbsoluteUri); + Assert.Equal(Uri.UriSchemeHttp, dashboardUri.Scheme); + Assert.True(dashboardUri.IsLoopback); + Assert.InRange(dashboardUri.Port, 1, 65535); + Assert.Equal("/login", dashboardUri.AbsolutePath); + Assert.StartsWith("?t=", dashboardUri.Query, StringComparison.Ordinal); + Assert.True(dashboardUri.Query.Length > 3); + + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true + }; + using var httpClient = new HttpClient(handler) + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var loginResponse = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + + Assert.Equal(HttpStatusCode.Redirect, loginResponse.StatusCode); + Assert.Equal("/", loginResponse.Headers.Location?.OriginalString); + Assert.Single( + loginResponse.Headers.GetValues("Set-Cookie"), + cookie => cookie.StartsWith(".Aspire.Dashboard.Auth", StringComparison.Ordinal)); + + using var protectedResponse = await httpClient.GetAsync( + new Uri(dashboardUri.GetLeftPart(UriPartial.Authority) + "/structuredlogs"), + cancellationTokenSource.Token); + Assert.Equal(HttpStatusCode.OK, protectedResponse.StatusCode); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task ConcurrentApplicationsUseDifferentDashboardLoginUrls() + { + await using var firstBuilder = await CreateDashboardBuilderAsync(); + await using var secondBuilder = await CreateDashboardBuilderAsync(); + await using var firstApp = await firstBuilder.BuildAsync(); + await using var secondApp = await secondBuilder.BuildAsync(); + + await Task.WhenAll(firstApp.StartAsync(), secondApp.StartAsync()).WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var urls = await Task.WhenAll( + firstApp.GetDashboardLoginUrlAsync(cancellationTokenSource.Token), + secondApp.GetDashboardLoginUrlAsync(cancellationTokenSource.Token)); + + Assert.Equal(Uri.UriSchemeHttp, urls[0].Scheme); + Assert.Equal(Uri.UriSchemeHttp, urls[1].Scheme); + Assert.NotEqual(urls[0].Port, urls[1].Port); + Assert.NotEqual(urls[0].Query, urls[1].Query); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() + { + await using var firstBuilder = await CreateDashboardBuilderAsync(); + await using var secondBuilder = await CreateDashboardBuilderAsync(); + await using var firstApp = await firstBuilder.BuildAsync(); + await using var secondApp = await secondBuilder.BuildAsync(); + + await Task.WhenAll(firstApp.StartAsync(), secondApp.StartAsync()).WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var firstUrl = await firstApp.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + var secondUrl = await secondApp.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + var firstBaseUrl = firstUrl.GetLeftPart(UriPartial.Authority); + Uri[] invalidLoginUrls = + [ + new($"{firstBaseUrl}/login?t=wrong-token"), + new(firstBaseUrl + secondUrl.PathAndQuery), + new($"{firstBaseUrl}/login?t=%25") + ]; + + foreach (var invalidLoginUrl in invalidLoginUrls) + { + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true + }; + using var httpClient = new HttpClient(handler) + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var response = await httpClient.GetAsync(invalidLoginUrl, cancellationTokenSource.Token); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/login", response.Headers.Location?.OriginalString); + Assert.False(response.Headers.TryGetValues("Set-Cookie", out _)); + } + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardLoginUrlAsyncThrowsWhenDashboardIsDisabled() + { + var builder = DistributedApplicationTestingBuilder.Create(); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardLoginUrlAsync(default)); + + Assert.Equal("The dashboard is not enabled for this application.", exception.Message); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardLoginUrlAsyncThrowsWhenDashboardAllowsAnonymousAccess() + { + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["AppHost:BrowserToken"] = string.Empty; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardLoginUrlAsync(default)); + + Assert.Equal( + "The dashboard login URL is not available because anonymous dashboard access is enabled.", + exception.Message); + } + + [Fact] + public async Task GetDashboardLoginUrlAsyncThrowsBeforeApplicationStarts() + { + await using var builder = await CreateDashboardBuilderAsync(); + await using var app = await builder.BuildAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardLoginUrlAsync(default)); + + Assert.Equal("The application must be started before retrieving the dashboard login URL.", exception.Message); + } + + [Fact] + public async Task GetDashboardLoginUrlAsyncHonorsCancellationAfterDashboardWaitStarts() + { + var (app, notificationLogger) = await CreateApplicationWithBlockedDashboardAsync(); + await using var _ = app; + using var cancellationTokenSource = new CancellationTokenSource(); + var loginUrlTask = app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + + Assert.Contains( + notificationLogger.Collector.GetSnapshot(), + entry => entry.Message.Contains( + $"Waiting for resource '{KnownResourceNames.AspireDashboard}'", + StringComparison.Ordinal)); + Assert.False(loginUrlTask.IsCompleted); + + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => loginUrlTask); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardLoginUrlAsyncThrowsAfterApplicationIsDisposed() + { + await using var builder = await CreateDashboardBuilderAsync(); + var app = await builder.BuildAsync(); + + try + { + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + _ = await app.GetDashboardLoginUrlAsync(default); + await app.DisposeAsync(); + + await Assert.ThrowsAsync( + () => app.GetDashboardLoginUrlAsync(default)); + } + finally + { + await app.DisposeAsync(); + } + } + + [Fact] + public async Task GetDashboardLoginUrlAsyncThrowsInPublishMode() + { + var builder = DistributedApplicationTestingBuilder.Create( + ["--publisher", "manifest"], + (options, _) => options.DisableDashboard = false); + await using var app = await builder.BuildAsync(); + + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardLoginUrlAsync(default)); + + Assert.Equal("The dashboard login URL is not available in publish mode.", exception.Message); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardLoginUrlAsyncPreservesTerminalDashboardFailure() + { + var missingDashboardPath = Path.Combine( + AppContext.BaseDirectory, + "missing-dashboard", + Guid.NewGuid().ToString("N")); + var builder = DistributedApplicationTestingBuilder.Create( + CreateDashboardOptions(), + [$"DcpPublisher:DashboardPath={missingDashboardPath}"]); + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var exception = await Assert.ThrowsAsync( + () => app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token)); + + Assert.Contains(KnownResourceNames.AspireDashboard, exception.Message, StringComparison.Ordinal); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task CanonicalDashboardLoginUrlEscapesBrowserToken() + { + const string browserToken = "token+with&reserved=value"; + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["AppHost:BrowserToken"] = browserToken; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var logger = app.Services.GetRequiredService().CreateLogger("DashboardLoginUrlTests"); + var info = await DashboardUrlsHelper.GetDashboardConnectionInfoAsync( + app.Services, + logger, + cancellationTokenSource.Token); + Assert.NotNull(info.BaseUrlWithLoginToken); + + Assert.EndsWith( + $"/login?t={Uri.EscapeDataString(browserToken)}", + info.BaseUrlWithLoginToken, + StringComparison.Ordinal); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardLoginUrlAsyncUsesConfiguredTargetHost() + { + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["ASPNETCORE_URLS"] = "http://aspire-dashboard.dev.localhost:0"; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + + Assert.Equal("aspire-dashboard.dev.localhost", dashboardUri.Host); + } + + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task GetDashboardLoginUrlAsyncPrefersCodespacesUrl() + { + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["CODESPACES"] = "true"; + builder.Configuration["CODESPACE_NAME"] = "test-codespace"; + builder.Configuration["GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"] = "app.github.dev"; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + + Assert.StartsWith("test-codespace-", dashboardUri.Host, StringComparison.Ordinal); + Assert.EndsWith(".app.github.dev", dashboardUri.Host, StringComparison.Ordinal); + } + + private static Task CreateDashboardBuilderAsync(string[]? args = null) + { + return DistributedApplicationTestingBuilder.CreateAsync( + CreateDashboardOptions(), + args ?? []); + } + + private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() + { + return new() + { + EnableDashboard = true + }; + } + + private static async Task<(DistributedApplication App, FakeLogger NotificationLogger)> + CreateApplicationWithBlockedDashboardAsync() + { + var dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, "dashboard", "."); + var notificationLogger = new FakeLogger(); + var host = new HostBuilder() + .ConfigureServices(services => + { + services.AddLogging(); + services.AddSingleton(new DistributedApplicationModel([dashboardResource])); + services.AddSingleton(new DistributedApplicationOptions + { + DisableDashboard = false + }); + services.AddSingleton(serviceProvider => + new DistributedApplicationExecutionContext( + new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) + { + Services = serviceProvider + })); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + new ResourceNotificationService( + notificationLogger, + serviceProvider.GetRequiredService(), + serviceProvider, + serviceProvider.GetRequiredService())); + services.AddSingleton( + new ProfilingTelemetry(new ConfigurationBuilder().Build())); + }) + .Build(); + await host.StartAsync(); + + return (new DistributedApplication(host), notificationLogger); + } +} diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs index 537a2f19957..1106c3a32f6 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -5,6 +5,7 @@ using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Xunit; @@ -80,6 +81,24 @@ public async Task DashboardTestingDefaultsOverrideConfigurationPresentDuringCrea Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration["AppHost:ResourceService:AuthMode"]); } + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingDefaultsOverrideAppHostConfiguration(CreationSurface creationSurface) + { + await using var builder = await CreateDashboardBuilderAsync( + creationSurface, + ["--override-dashboard-testing-defaults"]); + + Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[AspNetCoreUrls]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpGrpcEndpointUrl]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpHttpEndpointUrl]); + Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); + Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); + Assert.Equal("false", builder.Configuration[InteractivityEnabled]); + } + [Fact] public async Task DashboardTestingDefaultsAreNonInteractiveAndFailFast() { @@ -134,16 +153,100 @@ public async Task ExistingDefaultCallsRemainUnambiguous() await DistributedApplicationTestingBuilder.CreateAsync(default); await using var typeBuilder = await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), default); + + Func> genericOptionsCall = () => + DistributedApplicationTestingBuilder.CreateAsync( + CreateDashboardOptions(), + [], + default); + Func> typeOptionsCall = () => + DistributedApplicationTestingBuilder.CreateAsync( + typeof(Projects.TestingAppHost1_AppHost), + CreateDashboardOptions(), + [], + default); + + Assert.NotNull(genericOptionsCall); + Assert.NotNull(typeOptionsCall); + } + + [Fact] + public async Task BuildAsyncWithPreCanceledTokenDoesNotReleaseAppHost() + { + var probe = TestingAppHostBuildProbe.Create(); + var builder = + await DistributedApplicationTestingBuilder.CreateAsync( + [$"--block-apphost-build={probe.Id}"]); + try + { + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync( + () => builder.BuildAsync(cancellationTokenSource.Token)); + await Assert.ThrowsAsync( + () => probe.BuildEntered.WaitAsync(TimeSpan.FromMilliseconds(500))); + } + finally + { + probe.ContinueBuilding(); + await builder.DisposeAsync(); + probe.Dispose(); + } + } + + [Fact] + public async Task BuildAsyncCancellationAfterReleaseDisposesBuiltApplication() + { + var probe = TestingAppHostBuildProbe.Create(); + var builder = + await DistributedApplicationTestingBuilder.CreateAsync( + [$"--block-apphost-build={probe.Id}"]); + try + { + using var cancellationTokenSource = new CancellationTokenSource(); + var buildTask = builder.BuildAsync(cancellationTokenSource.Token); + await probe.BuildEntered.DefaultTimeout(); + Assert.False(buildTask.IsCompleted); + + cancellationTokenSource.Cancel(); + await Task.Delay(50); + probe.ContinueBuilding(); + + await Assert.ThrowsAnyAsync(() => buildTask); + await probe.ApplicationDisposed.DefaultTimeout(); + } + finally + { + probe.ContinueBuilding(); + await builder.DisposeAsync(); + probe.Dispose(); + } + } + + [Fact] + public async Task BuildAsyncAfterBuilderDisposedThrowsObjectDisposedException() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + await builder.DisposeAsync(); + + await Assert.ThrowsAsync(() => builder.BuildAsync()); } [Theory] - [InlineData(CreationSurface.Generic)] - [InlineData(CreationSurface.Type)] - [InlineData(CreationSurface.AdHoc)] - public async Task DashboardTestingIsRejectedInPublishMode(CreationSurface creationSurface) + [InlineData(CreationSurface.Generic, "--operation", "publish")] + [InlineData(CreationSurface.Generic, "--publisher", "manifest")] + [InlineData(CreationSurface.Type, "--operation", "publish")] + [InlineData(CreationSurface.Type, "--publisher", "manifest")] + [InlineData(CreationSurface.AdHoc, "--operation", "publish")] + [InlineData(CreationSurface.AdHoc, "--publisher", "manifest")] + public async Task DashboardTestingIsRejectedInPublishMode( + CreationSurface creationSurface, + string argumentName, + string argumentValue) { var exception = await Assert.ThrowsAsync( - () => CreatePublishBuilderAsync(creationSurface)); + () => CreatePublishBuilderAsync(creationSurface, [argumentName, argumentValue])); Assert.Equal("Dashboard testing is not supported in publish mode.", exception.Message); } @@ -171,10 +274,9 @@ private static async Task CreateDashboard }; } - private static async Task CreatePublishBuilderAsync(CreationSurface creationSurface) + private static async Task CreatePublishBuilderAsync(CreationSurface creationSurface, string[] args) { var options = CreateDashboardOptions(); - string[] args = ["--publisher", "manifest"]; var builder = creationSurface switch { diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs deleted file mode 100644 index c805f9fa945..00000000000 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.TestUtilities; -using Microsoft.AspNetCore.InternalTesting; -using Xunit; - -namespace Aspire.Hosting.Testing.Tests; - -public class DashboardUrlTests -{ - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task GetDashboardUrlAsyncReturnsReachableAuthenticatedDynamicLoopbackUrl() - { - var builder = CreateDashboardBuilder(); - await using var app = await builder.BuildAsync(); - await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - - using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); - - Assert.True(dashboardUri.IsAbsoluteUri); - Assert.Equal(Uri.UriSchemeHttp, dashboardUri.Scheme); - Assert.True(dashboardUri.IsLoopback); - Assert.InRange(dashboardUri.Port, 1, 65535); - Assert.Equal("/login", dashboardUri.AbsolutePath); - Assert.StartsWith("?t=", dashboardUri.Query, StringComparison.Ordinal); - Assert.True(dashboardUri.Query.Length > 3); - - using var httpClient = new HttpClient - { - Timeout = TestConstants.LongTimeoutTimeSpan - }; - using var response = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); - Assert.True(response.IsSuccessStatusCode, $"The dashboard returned HTTP status code {(int)response.StatusCode}."); - } - - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task ConcurrentApplicationsUseDifferentDashboardUrls() - { - var firstBuilder = CreateDashboardBuilder(); - var secondBuilder = CreateDashboardBuilder(); - await using var firstApp = await firstBuilder.BuildAsync(); - await using var secondApp = await secondBuilder.BuildAsync(); - - await Task.WhenAll(firstApp.StartAsync(), secondApp.StartAsync()).WaitAsync(TestConstants.LongTimeoutTimeSpan); - - using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var urls = await Task.WhenAll( - firstApp.GetDashboardUrlAsync(cancellationTokenSource.Token), - secondApp.GetDashboardUrlAsync(cancellationTokenSource.Token)); - - Assert.Equal(Uri.UriSchemeHttp, urls[0].Scheme); - Assert.Equal(Uri.UriSchemeHttp, urls[1].Scheme); - Assert.NotEqual(urls[0].Port, urls[1].Port); - } - - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task GetDashboardUrlAsyncThrowsWhenDashboardIsDisabled() - { - var builder = DistributedApplicationTestingBuilder.Create(); - await using var app = await builder.BuildAsync(); - await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - - var exception = await Assert.ThrowsAsync( - () => app.GetDashboardUrlAsync(default)); - - Assert.Equal("The dashboard is not enabled for this application.", exception.Message); - } - - [Fact] - public async Task GetDashboardUrlAsyncThrowsBeforeApplicationStarts() - { - var builder = CreateDashboardBuilder(); - await using var app = await builder.BuildAsync(); - - var exception = await Assert.ThrowsAsync( - () => app.GetDashboardUrlAsync(default)); - - Assert.Equal("The application must be started before retrieving the dashboard URL.", exception.Message); - } - - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task GetDashboardUrlAsyncHonorsCancellation() - { - var builder = CreateDashboardBuilder(); - await using var app = await builder.BuildAsync(); - await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); - - await Assert.ThrowsAnyAsync( - () => app.GetDashboardUrlAsync(cancellationTokenSource.Token)); - } - - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task GetDashboardUrlAsyncThrowsAfterApplicationIsDisposed() - { - var builder = CreateDashboardBuilder(); - var app = await builder.BuildAsync(); - - try - { - await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - _ = await app.GetDashboardUrlAsync(default); - await app.DisposeAsync(); - - await Assert.ThrowsAsync( - () => app.GetDashboardUrlAsync(default)); - } - finally - { - await app.DisposeAsync(); - } - } - - [Fact] - public async Task GetDashboardUrlAsyncThrowsInPublishMode() - { - var builder = DistributedApplicationTestingBuilder.Create( - ["--publisher", "manifest"], - (options, _) => options.DisableDashboard = false); - await using var app = await builder.BuildAsync(); - - var exception = await Assert.ThrowsAsync( - () => app.GetDashboardUrlAsync(default)); - - Assert.Equal("The dashboard URL is not available in publish mode.", exception.Message); - } - - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task GetDashboardUrlAsyncPreservesTerminalDashboardFailure() - { - var missingDashboardPath = Path.Combine( - AppContext.BaseDirectory, - "missing-dashboard", - Guid.NewGuid().ToString("N")); - var builder = DistributedApplicationTestingBuilder.Create( - CreateDashboardOptions(), - [$"DcpPublisher:DashboardPath={missingDashboardPath}"]); - await using var app = await builder.BuildAsync(); - await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - - using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var exception = await Assert.ThrowsAsync( - () => app.GetDashboardUrlAsync(cancellationTokenSource.Token)); - - Assert.Equal( - "Stopped waiting for resource 'aspire-dashboard' to become healthy because it failed to start.", - exception.Message); - } - - private static IDistributedApplicationTestingBuilder CreateDashboardBuilder() - { - return DistributedApplicationTestingBuilder.Create(CreateDashboardOptions(), []); - } - - private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() - { - return new() - { - EnableDashboard = true - }; - } -} diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index de5634460ce..5516ccf2e7a 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -9,6 +9,25 @@ var builder = DistributedApplication.CreateBuilder(args); +var buildProbeArgument = args.FirstOrDefault( + argument => argument.StartsWith("--block-apphost-build=", StringComparison.Ordinal)); +if (buildProbeArgument is not null) +{ + var buildProbeId = buildProbeArgument["--block-apphost-build=".Length..]; + TestingAppHostBuildProbe.Configure(builder, buildProbeId); +} + +if (args.Contains("--override-dashboard-testing-defaults")) +{ + builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; + builder.Configuration["ASPNETCORE_URLS"] = "http://127.0.0.1:12345"; + builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://127.0.0.1:12346"; + builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://127.0.0.1:12347"; + builder.Configuration["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "http://127.0.0.1:12348"; + builder.Configuration["ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"] = "true"; + builder.Configuration["ASPIRE_INTERACTIVITY_ENABLED"] = "true"; +} + builder.Configuration["ConnectionStrings:cs"] = "testconnection"; builder.AddConnectionString("cs"); diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs new file mode 100644 index 00000000000..d68277bcfd5 --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs @@ -0,0 +1,133 @@ +// 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 Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +public static class TestingAppHostBuildProbe +{ + private static readonly ConcurrentDictionary s_probes = new(); + + public static Probe Create() + { + var id = Guid.NewGuid().ToString("N"); + var state = new ProbeState(); + if (!s_probes.TryAdd(id, state)) + { + throw new InvalidOperationException($"Could not create build probe '{id}'."); + } + + return new Probe(id, state); + } + + internal static void Configure(IDistributedApplicationBuilder builder, string id) + { + if (!s_probes.TryGetValue(id, out var state)) + { + throw new InvalidOperationException($"Build probe '{id}' was not registered."); + } + + // The testing factory pauses in its OnBuilding callback. Blocking the first resource-name read after that + // callback creates a deterministic window where BuildAsync has released the AppHost but Build has not finished. + builder.Resources.Add(new BlockingResource($"build-probe-{id}", builder.Services, state)); + state.Arm(); + } + + public sealed class Probe : IDisposable + { + private readonly ProbeState _state; + + internal Probe(string id, ProbeState state) + { + Id = id; + _state = state; + } + + public string Id { get; } + + public Task BuildEntered => _state.BuildEntered.Task; + + public Task ApplicationDisposed => _state.ApplicationDisposed.Task; + + public void ContinueBuilding() + { + _state.ContinueBuilding.TrySetResult(); + } + + public void Dispose() + { + ContinueBuilding(); + s_probes.TryRemove(Id, out _); + } + } + + internal sealed class ProbeState + { + private int _armed; + private int _buildObserved; + + public TaskCompletionSource BuildEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ContinueBuilding { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ApplicationDisposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Arm() + { + Volatile.Write(ref _armed, 1); + } + + public bool TryObserveBuild() + { + return Volatile.Read(ref _armed) == 1 && + Interlocked.CompareExchange(ref _buildObserved, 1, 0) == 0; + } + } + + private sealed class BlockingResource( + string name, + IServiceCollection services, + ProbeState state) : Resource(name) + { + public override string Name + { + get + { + if (state.TryObserveBuild()) + { + var hostDescriptor = services.Single( + descriptor => descriptor.ServiceType == typeof(IHost) && descriptor.ServiceKey is null); + var hostFactory = hostDescriptor.ImplementationFactory + ?? throw new InvalidOperationException("The AppHost IHost registration did not use a factory."); + + // Resolve the tracker as part of IHost creation so disposal proof does not depend on the + // application reaching StartAsync after a canceled build. + services.Remove(hostDescriptor); + services.AddSingleton(_ => new ProbeDisposalTracker(state)); + services.Add(ServiceDescriptor.Describe( + typeof(IHost), + serviceProvider => + { + _ = serviceProvider.GetRequiredService(); + return hostFactory(serviceProvider); + }, + hostDescriptor.Lifetime)); + + state.BuildEntered.TrySetResult(); + state.ContinueBuilding.Task.GetAwaiter().GetResult(); + } + + return base.Name; + } + } + } + + private sealed class ProbeDisposalTracker(ProbeState state) : IDisposable + { + public void Dispose() + { + state.ApplicationDisposed.TrySetResult(); + } + } +} From 51f8c6b9b40b2ba816c7d4b2e6603e407aaddc5e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 10:51:37 -0400 Subject: [PATCH 03/21] Address review findings for dashboard-in-tests support Lifecycle correctness: - BuildAsync no longer blocks on the released AppHost when the caller's token fires. It reclaims the late application on a background continuation and rethrows immediately, so cancellation is prompt even when the AppHost never finishes building. - DistributedApplicationFactory.OnBuiltCoreAsync disposes an application that arrives after the factory was disposed. Previously TrySetResult simply returned false and the built DistributedApplication, its service provider, and its orchestrator processes leaked for the lifetime of the test process. - DisposeAsync claims disposal with Interlocked.Exchange. The previous IsCancellationRequested read was not atomic with OnDisposed(), so concurrent disposers could both run teardown and race on the same application. - ObjectDisposedException now consistently reports IDistributedApplicationTestingBuilder rather than leaking the internal factory type name. Behavior: - DistributedApplicationOptions.DisableDashboard = false, the pre-existing spelling of "run the dashboard", now receives the same hardened testing defaults as the new EnableDashboard option instead of only one of them being hardened. - Dashboard endpoints are configured with an empty URL, which is how the product asks for a dynamically assigned port. The previous "http://127.0.0.1:0" parses to a literal fixed port 0 and was only dynamic while DcpPublisher:RandomizePorts stayed true. - A fresh browser token is generated per application and supplied through the command line, which has the highest precedence. Disabling anonymous access only closes the door if a credential exists, and an ambient ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN would otherwise share one known token across every application on a CI agent. - DistributedApplicationTestingBuilderOptions.DefaultWaitBehavior lets a debugging session keep a stuck resource alive to inspect it, rather than always tearing the application down. Conventions: - Exception messages moved to Properties/Resources.resx with regenerated xlf, matching the rest of the file. - Commented the Aspire.Hosting InternalsVisibleTo grant, including why the shared file links had to be dropped. - Replaced vacuous Assert.NotNull-on-a-lambda assertions with assertions that exercise the overloads, and added coverage for per-application token isolation, DefaultWaitBehavior, publish-mode rejection through configureBuilder, and disposal of a late-arriving application. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3f649b5-52c4-4eb5-8d74-c4744e79faf4 --- .../DistributedApplicationFactory.cs | 31 ++- ...utedApplicationHostingTestingExtensions.cs | 16 +- .../DistributedApplicationTestingBuilder.cs | 185 +++++++++++----- ...ributedApplicationTestingBuilderOptions.cs | 49 ++++- .../Properties/Resources.Designer.cs | 54 +++++ .../Properties/Resources.resx | 18 ++ .../Properties/xlf/Resources.cs.xlf | 30 +++ .../Properties/xlf/Resources.de.xlf | 30 +++ .../Properties/xlf/Resources.es.xlf | 30 +++ .../Properties/xlf/Resources.fr.xlf | 30 +++ .../Properties/xlf/Resources.it.xlf | 30 +++ .../Properties/xlf/Resources.ja.xlf | 30 +++ .../Properties/xlf/Resources.ko.xlf | 30 +++ .../Properties/xlf/Resources.pl.xlf | 30 +++ .../Properties/xlf/Resources.pt-BR.xlf | 30 +++ .../Properties/xlf/Resources.ru.xlf | 30 +++ .../Properties/xlf/Resources.tr.xlf | 30 +++ .../Properties/xlf/Resources.zh-Hans.xlf | 30 +++ .../Properties/xlf/Resources.zh-Hant.xlf | 30 +++ src/Aspire.Hosting/Aspire.Hosting.csproj | 5 + .../DashboardLoginUrlTests.cs | 15 +- .../DashboardTestingBuilderTests.cs | 203 +++++++++++++++--- .../TestingAppHost1.AppHost/Program.cs | 6 + .../TestingAppHostBuildProbe.cs | 14 ++ 24 files changed, 867 insertions(+), 119 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs index 7af9dd22dd5..826766f4388 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs @@ -26,6 +26,11 @@ public class DistributedApplicationFactory(Type entryPoint, string[] args) : IDi private readonly TaskCompletionSource _builderTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _appTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly CancellationTokenSource _disposingCts = new(); + + // Disposal must be claimed atomically. Reading _disposingCts.IsCancellationRequested is not enough because + // OnDisposed() cancels it only after the guard, so two concurrent disposers could both run the teardown + // below and race on StopAsync()/DisposeAsync() of the same application. + private int _disposeClaimed; private TimeSpan _shutdownTimeout = TimeSpan.FromSeconds(10); private readonly object _lockObj = new(); private bool _entryPointStarted; @@ -166,10 +171,28 @@ private static string[] ThrowIfNullOrContainsIsNullOrEmpty(string[] args) return args; } - private void OnBuiltCore(DistributedApplication application) + private async Task OnBuiltCoreAsync(DistributedApplication application) { _shutdownTimeout = application.Services.GetService>()?.Value.ShutdownTimeout ?? _shutdownTimeout; - _appTcs.TrySetResult(application); + + if (!_appTcs.TrySetResult(application)) + { + // The factory was disposed, or the entry point already faulted, while this application was still + // being built. No caller will ever receive it, so nothing else holds a reference that would tear + // down the host, its service provider, or the orchestrator processes it owns. Reclaim it here + // instead of leaving it running for the lifetime of the test process. + try + { + await application.DisposeAsync().ConfigureAwait(false); + } + catch + { + // No owner remains to observe a disposal failure for an application nobody asked for. + } + + return; + } + OnBuilt(application); } @@ -409,7 +432,7 @@ private async Task InvokeEntryPoint(Func() ?? throw new InvalidOperationException($"Application did not register an implementation of {typeof(IHostApplicationLifetime)}."); - OnBuiltCore(app); + await OnBuiltCoreAsync(app).ConfigureAwait(false); } catch (Exception exception) { @@ -481,7 +504,7 @@ private void OnDisposed() /// public virtual async ValueTask DisposeAsync() { - if (_disposingCts.IsCancellationRequested) + if (Interlocked.Exchange(ref _disposeClaimed, 1) == 1) { // Dispose already called. return; diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs index 59dd8c1e2bf..9d02b48d1b1 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs @@ -14,12 +14,6 @@ namespace Aspire.Hosting.Testing; /// public static class DistributedApplicationHostingTestingExtensions { - private const string DashboardDisabledExceptionMessage = "The dashboard is not enabled for this application."; - private const string DashboardLoginUrlAnonymousExceptionMessage = "The dashboard login URL is not available because anonymous dashboard access is enabled."; - private const string DashboardLoginUrlApplicationNotStartedExceptionMessage = "The application must be started before retrieving the dashboard login URL."; - private const string DashboardLoginUrlPublishModeExceptionMessage = "The dashboard login URL is not available in publish mode."; - private const string DashboardLoginUrlUnavailableExceptionMessage = "The dashboard login URL is not available."; - /// /// Gets the authenticated login URL for the running Aspire dashboard. /// @@ -74,16 +68,16 @@ public static async Task GetDashboardLoginUrlAsync( var executionContext = app.Services.GetRequiredService(); if (executionContext.IsPublishMode) { - throw new InvalidOperationException(DashboardLoginUrlPublishModeExceptionMessage); + throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlPublishModeExceptionMessage); } var applicationOptions = app.Services.GetRequiredService(); if (applicationOptions.DisableDashboard) { - throw new InvalidOperationException(DashboardDisabledExceptionMessage); + throw new InvalidOperationException(Properties.Resources.DashboardDisabledExceptionMessage); } - ThrowIfNotStarted(app, DashboardLoginUrlApplicationNotStartedExceptionMessage); + ThrowIfNotStarted(app, Properties.Resources.DashboardLoginUrlApplicationNotStartedExceptionMessage); cancellationToken.ThrowIfCancellationRequested(); var logger = app.Services.GetRequiredService().CreateLogger( @@ -96,12 +90,12 @@ public static async Task GetDashboardLoginUrlAsync( var dashboardUrl = dashboardInfo.CodespacesUrlWithLoginToken ?? dashboardInfo.BaseUrlWithLoginToken; if (!dashboardInfo.IsHealthy || !Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri)) { - throw new InvalidOperationException(DashboardLoginUrlUnavailableExceptionMessage); + throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlUnavailableExceptionMessage); } if (!dashboardInfo.HasBrowserToken) { - throw new InvalidOperationException(DashboardLoginUrlAnonymousExceptionMessage); + throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlAnonymousExceptionMessage); } return dashboardUri; diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 2c64b893829..e3b474bf5e9 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -22,7 +22,6 @@ namespace Aspire.Hosting.Testing; /// public static class DistributedApplicationTestingBuilder { - private const string DashboardTestingPublishModeExceptionMessage = "Dashboard testing is not supported in publish mode."; /// /// Creates a new instance of . @@ -118,7 +117,7 @@ public static Task CreateAsync( { ArgumentNullException.ThrowIfNull(options); - return CreateAsyncCore(entryPoint, args, options.EnableDashboard, (_, __) => { }, cancellationToken); + return CreateAsyncCore(entryPoint, args, options, (_, __) => { }, cancellationToken); } /// @@ -178,12 +177,12 @@ public static Task CreateAsync [SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Generic and non-generic")] public static async Task CreateAsync(Type entryPoint, string[] args, Action configureBuilder, CancellationToken cancellationToken = default) - => await CreateAsyncCore(entryPoint, args, enableDashboard: false, configureBuilder, cancellationToken).ConfigureAwait(false); + => await CreateAsyncCore(entryPoint, args, testingOptions: null, configureBuilder, cancellationToken).ConfigureAwait(false); private static async Task CreateAsyncCore( Type entryPoint, string[] args, - bool enableDashboard, + DistributedApplicationTestingBuilderOptions? testingOptions, Action configureBuilder, CancellationToken cancellationToken) { @@ -191,7 +190,7 @@ private static async Task CreateAsyncCore ThrowIfNullOrContainsIsNullOrEmpty(args); ArgumentNullException.ThrowIfNull(configureBuilder, nameof(configureBuilder)); - var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, enableDashboard, configureBuilder); + var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, testingOptions, configureBuilder); try { return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); @@ -239,7 +238,7 @@ public static IDistributedApplicationTestingBuilder Create( { ArgumentNullException.ThrowIfNull(options); - return CreateCore(args, options.EnableDashboard, (_, __) => { }); + return CreateCore(args, options, (_, __) => { }); } /// @@ -251,18 +250,18 @@ public static IDistributedApplicationTestingBuilder Create( /// A new instance of . /// public static IDistributedApplicationTestingBuilder Create(string[] args, Action configureBuilder) - => CreateCore(args, enableDashboard: false, configureBuilder); + => CreateCore(args, testingOptions: null, configureBuilder); private static IDistributedApplicationTestingBuilder CreateCore( string[] args, - bool enableDashboard, + DistributedApplicationTestingBuilderOptions? testingOptions, Action configureBuilder, Assembly? appHostAssembly = null) { ThrowIfNullOrContainsIsNullOrEmpty(args); ArgumentNullException.ThrowIfNull(configureBuilder); - return new TestingBuilder(args, enableDashboard, configureBuilder, appHostAssembly); + return new TestingBuilder(args, testingOptions, configureBuilder, appHostAssembly); } /// @@ -278,27 +277,51 @@ internal static IDistributedApplicationTestingBuilder Create( string[] args, Action configureBuilder, Assembly appHostAssembly) - => CreateCore(args, enableDashboard: false, configureBuilder, appHostAssembly); + => CreateCore(args, testingOptions: null, configureBuilder, appHostAssembly); + + private static bool IsDashboardTestingEnabled( + DistributedApplicationOptions applicationOptions, + DistributedApplicationTestingBuilderOptions? testingOptions) + { + // Setting DistributedApplicationOptions.DisableDashboard = false through the configureBuilder callback was + // already the supported way to run a dashboard from a test, so treat it as equivalent to the option. Both + // spellings must converge here or only one of them gets the hardened testing defaults below. + return testingOptions?.EnableDashboard == true || !applicationOptions.DisableDashboard; + } private static void ConfigureDashboardTesting( DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostBuilderOptions, - bool enableDashboard) + DistributedApplicationTestingBuilderOptions? testingOptions, + out DashboardTestingState dashboardTestingState) { - if (!enableDashboard) + if (!IsDashboardTestingEnabled(applicationOptions, testingOptions)) { + dashboardTestingState = default; return; } + dashboardTestingState = new DashboardTestingState( + Enabled: true, + DefaultWaitBehavior: testingOptions?.DefaultWaitBehavior ?? WaitBehavior.StopOnResourceUnavailable); + 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. hostBuilderOptions.Args = [ .. (hostBuilderOptions.Args ?? applicationOptions.Args ?? []), - $"{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=false" + $"{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=false", + $"{KnownConfigNames.DashboardFrontendBrowserToken}={TokenGenerator.GenerateToken()}" ]; applicationOptions.Args = hostBuilderOptions.Args; @@ -306,16 +329,16 @@ private static void ConfigureDashboardTesting( AddDashboardTestingConfiguration(hostBuilderOptions.Configuration); } - private static void ConfigureDashboardTesting(IDistributedApplicationBuilder builder, bool enableDashboard) + private static void ConfigureDashboardTesting(IDistributedApplicationBuilder builder, DashboardTestingState dashboardTestingState) { - if (!enableDashboard) + if (!dashboardTestingState.Enabled) { return; } if (builder.ExecutionContext.IsPublishMode) { - throw new InvalidOperationException(DashboardTestingPublishModeExceptionMessage); + throw new InvalidOperationException(Properties.Resources.DashboardTestingPublishModeExceptionMessage); } // Apply these after the builder has loaded environment variables and command-line arguments so test @@ -324,11 +347,13 @@ private static void ConfigureDashboardTesting(IDistributedApplicationBuilder bui // selection, including dashboard authentication, has already completed. AddDashboardTestingConfiguration(builder.Configuration); - // Enabling the dashboard changes the hosting default to wait indefinitely when a dependency becomes - // unavailable. Tests must retain the testing builder's fail-fast behavior, while a later user - // registration can still override this default. + // Enabling the dashboard makes the hosting default wait indefinitely when a dependency becomes unavailable, + // which would hang a test run instead of failing it. Re-pin the testing builder's fail-fast default unless + // the caller asked for something else, since waiting is the useful behavior when the whole point is to open + // the dashboard and look at the stuck resource. A later user registration still overrides this. + var defaultWaitBehavior = dashboardTestingState.DefaultWaitBehavior; builder.Services.Configure( - options => options.DefaultWaitBehavior = WaitBehavior.StopOnResourceUnavailable); + options => options.DefaultWaitBehavior = defaultWaitBehavior); } private static void AddDashboardTestingConfiguration(IConfigurationBuilder configuration) @@ -336,10 +361,20 @@ private static void AddDashboardTestingConfiguration(IConfigurationBuilder confi configuration.AddInMemoryCollection(new Dictionary { ["DcpPublisher:RandomizePorts"] = "true", - [KnownAspNetCoreConfigNames.Urls] = "http://127.0.0.1:0", - [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://127.0.0.1:0", - [KnownConfigNames.DashboardOtlpHttpEndpointUrl] = "http://127.0.0.1:0", + + // 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" @@ -363,20 +398,38 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) } } + /// + /// 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. + /// + private readonly record struct DashboardTestingState(bool Enabled, WaitBehavior DefaultWaitBehavior); + private sealed class SuspendingDistributedApplicationFactory( Type entryPoint, string[] args, - bool enableDashboard, + DistributedApplicationTestingBuilderOptions? testingOptions, Action configureBuilder) : DistributedApplicationFactory(entryPoint, args) { private readonly SemaphoreSlim _continueBuilding = new(0); + + // Tracks whether the suspended AppHost entry point has been released, and why. Disposal must win over a + // concurrent BuildAsync so a rejected builder cannot continue into Build() after the factory is gone. + private const int ContinuationSuspended = 0; + private const int ContinuationReleased = 1; + private const int ContinuationDisposed = 2; + private int _buildingContinuationState; + // Resolved while the builder is being constructed, from the effective DistributedApplicationOptions rather + // than the caller's options alone, so an AppHost that enables the dashboard through configureBuilder gets the + // same hardened testing defaults. + private DashboardTestingState _dashboardTestingState; + public async Task CreateBuilderAsync(CancellationToken cancellationToken) { var innerBuilder = await ResolveBuilderAsync(cancellationToken).ConfigureAwait(false); - ConfigureDashboardTesting(innerBuilder, enableDashboard); + ConfigureDashboardTesting(innerBuilder, _dashboardTestingState); return new Builder(this, innerBuilder); } @@ -384,7 +437,7 @@ protected override void OnBuilderCreating(DistributedApplicationOptions applicat { base.OnBuilderCreating(applicationOptions, hostOptions); configureBuilder(applicationOptions, hostOptions); - ConfigureDashboardTesting(applicationOptions, hostOptions, enableDashboard); + ConfigureDashboardTesting(applicationOptions, hostOptions, testingOptions, out _dashboardTestingState); } protected override void OnBuilding(DistributedApplicationBuilder applicationBuilder) @@ -393,11 +446,9 @@ protected override void OnBuilding(DistributedApplicationBuilder applicationBuil // Wait until the owner signals that building can continue by calling BuildAsync(). _continueBuilding.Wait(); - if (Volatile.Read(ref _buildingContinuationState) == 2) + if (Volatile.Read(ref _buildingContinuationState) == ContinuationDisposed) { - throw new ObjectDisposedException( - nameof(IDistributedApplicationTestingBuilder), - "The testing builder was disposed before the application was built."); + throw CreateDisposedException(); } } @@ -405,9 +456,13 @@ public async Task BuildAsync(CancellationToken cancellat { cancellationToken.ThrowIfCancellationRequested(); - var previousState = Interlocked.CompareExchange(ref _buildingContinuationState, 1, 0); - ObjectDisposedException.ThrowIf(previousState == 2, this); - if (previousState == 0) + var previousState = Interlocked.CompareExchange(ref _buildingContinuationState, ContinuationReleased, ContinuationSuspended); + if (previousState == ContinuationDisposed) + { + throw CreateDisposedException(); + } + + if (previousState == ContinuationSuspended) { _continueBuilding.Release(); } @@ -419,25 +474,50 @@ public async Task BuildAsync(CancellationToken cancellat catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // Once the AppHost has been released, it can still finish building after the caller stops waiting. - // Wait for that application and dispose the factory before propagating cancellation so it cannot - // continue running without an owner. + // Cancellation must return to the caller promptly, so reclaim that application on a background + // continuation rather than blocking here until an AppHost that may never finish completes. + ReclaimApplicationInBackground(); + throw; + } + catch (OperationCanceledException) when (Volatile.Read(ref _buildingContinuationState) == ContinuationDisposed) + { + throw CreateDisposedException(); + } + } + + /// + /// Disposes the factory once a released AppHost finishes building, so an application the caller abandoned + /// after cancellation cannot keep running. + /// + /// + /// This is best effort. If the caller disposes the builder first, this wait is aborted by that disposal and + /// reclaims the late-arriving application instead. + /// + private void ReclaimApplicationInBackground() + { + _ = Task.Run(async () => + { try { _ = await ResolveApplicationAsync(CancellationToken.None).ConfigureAwait(false); + await DisposeAsync().ConfigureAwait(false); } - finally + catch { - await DisposeAsync().ConfigureAwait(false); + // The AppHost can fail, or this wait can be aborted by the caller's own disposal, after the + // caller stopped waiting. No caller remains to observe that, and the factory reclaims any + // application that arrives after disposal, so swallow it rather than raising an unobserved + // task exception on the finalizer thread. } + }); + } - throw; - } - catch (OperationCanceledException) when (Volatile.Read(ref _buildingContinuationState) == 2) - { - throw new ObjectDisposedException( - nameof(IDistributedApplicationTestingBuilder), - "The testing builder was disposed before the application was built."); - } + private static ObjectDisposedException CreateDisposedException() + { + // Report the public interface rather than this internal factory type so callers see the object they own. + return new ObjectDisposedException( + nameof(IDistributedApplicationTestingBuilder), + "The testing builder was disposed before the application was built."); } public override async ValueTask DisposeAsync() @@ -454,8 +534,8 @@ public override void Dispose() private void PrepareForDisposal() { - var previousState = Interlocked.Exchange(ref _buildingContinuationState, 2); - if (previousState == 0) + var previousState = Interlocked.Exchange(ref _buildingContinuationState, ContinuationDisposed); + if (previousState == ContinuationSuspended) { // Abort on the AppHost entry-point thread instead of allowing a rejected or canceled // builder to continue into Build() after the factory has already been disposed. @@ -560,20 +640,21 @@ public async Task StopAsync(CancellationToken cancellationToken) private sealed class TestingBuilder( string[] args, - bool enableDashboard, + DistributedApplicationTestingBuilderOptions? testingOptions, Action configureBuilder, Assembly? appHostAssembly = null) : IDistributedApplicationTestingBuilder { - private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, enableDashboard, configureBuilder, appHostAssembly); + private readonly DistributedApplicationBuilder _innerBuilder = CreateInnerBuilder(args, testingOptions, configureBuilder, appHostAssembly); private DistributedApplication? _app; private static DistributedApplicationBuilder CreateInnerBuilder( string[] args, - bool enableDashboard, + DistributedApplicationTestingBuilderOptions? testingOptions, Action configureBuilder, Assembly? appHostAssembly = null) { + var dashboardTestingState = default(DashboardTestingState); var builder = TestingBuilderFactory.CreateBuilder(args, onConstructing: (applicationOptions, hostBuilderOptions) => { Assembly appAssembly; @@ -589,11 +670,11 @@ private static DistributedApplicationBuilder CreateInnerBuilder( DistributedApplicationFactory.ConfigureBuilder(args, applicationOptions, hostBuilderOptions, appAssembly, (options, settings) => { configureBuilder(options, settings); - ConfigureDashboardTesting(options, settings, enableDashboard); + ConfigureDashboardTesting(options, settings, testingOptions, out dashboardTestingState); }); }); - ConfigureDashboardTesting(builder, enableDashboard); + ConfigureDashboardTesting(builder, dashboardTestingState); if (!builder.Configuration.GetValue(KnownConfigNames.TestingDisableHttpClient, false)) { diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs index 9067f6ee713..9bc40895f95 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs @@ -1,22 +1,21 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.ApplicationModel; + namespace Aspire.Hosting.Testing; /// -/// Provides options for creating an . +/// Options that control how an is created. /// /// -/// Dashboard support is configured while the underlying distributed application builder is constructed because -/// the dashboard services cannot be added after construction. When enabled, the testing builder uses authenticated -/// HTTP endpoints on randomized loopback ports, disables interactivity, and retains the testing builder's fail-fast -/// dependency behavior. These testing defaults are reapplied after the AppHost has configured its builder. -/// Configuration applied directly to the returned builder can override runtime settings such as port allocation, -/// interactivity, and dependency waiting before the application is built. Dashboard authentication is selected while -/// the underlying builder is constructed. +/// These options are applied while the underlying distributed application builder is being constructed, because +/// dashboard services and dashboard authentication are selected during construction and cannot be added afterwards. +/// Settings that remain adjustable at runtime, such as port allocation, interactivity, and dependency waiting, can +/// still be overridden through the returned builder before the application is built. /// /// -/// The following example creates a testing builder with dashboard support: +/// The following example creates a testing builder that runs the dashboard: /// /// var options = new DistributedApplicationTestingBuilderOptions /// { @@ -24,16 +23,44 @@ namespace Aspire.Hosting.Testing; /// }; /// /// var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.MyAppHost_AppHost>(options, []); +/// await using var app = await builder.BuildAsync(); +/// await app.StartAsync(); +/// +/// // Open this in a browser to inspect the running application. +/// var loginUrl = await app.GetDashboardLoginUrlAsync(); /// /// public sealed class DistributedApplicationTestingBuilderOptions { /// - /// Gets or sets a value indicating whether the Aspire dashboard is enabled for the testing application. + /// Gets or sets a value indicating whether the Aspire dashboard runs alongside the application under test. /// /// - /// to enable the dashboard; otherwise, . + /// to run the dashboard; otherwise, . /// The default is . /// + /// + /// When the dashboard runs, it listens on authenticated loopback endpoints using dynamically assigned ports so + /// that concurrent test applications cannot collide or reach each other. Use + /// to obtain an + /// authenticated URL for the running dashboard. + /// public bool EnableDashboard { get; set; } + + /// + /// Gets or sets the default applied when a resource waits on a dependency that has + /// become unavailable. + /// + /// + /// The wait behavior to apply, or to use the default. + /// The default is , which fails the test run instead of + /// hanging it. + /// + /// + /// Set this to when debugging with + /// , so that a failing dependency keeps the application alive long enough to be + /// inspected in the dashboard instead of tearing it down. This option has no effect unless the dashboard runs, + /// because the testing builder already fails fast otherwise. + /// + public WaitBehavior? DefaultWaitBehavior { get; set; } } diff --git a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs index 43c44729bc4..0564c023cb2 100644 --- a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs +++ b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs @@ -69,6 +69,60 @@ internal static string ApplicationNotStartedExceptionMessage { } } + /// + /// Looks up a localized string similar to The dashboard is not enabled for this application.. + /// + internal static string DashboardDisabledExceptionMessage { + get { + return ResourceManager.GetString("DashboardDisabledExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The dashboard login URL is not available because anonymous dashboard access is enabled.. + /// + internal static string DashboardLoginUrlAnonymousExceptionMessage { + get { + return ResourceManager.GetString("DashboardLoginUrlAnonymousExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The application must be started before retrieving the dashboard login URL.. + /// + internal static string DashboardLoginUrlApplicationNotStartedExceptionMessage { + get { + return ResourceManager.GetString("DashboardLoginUrlApplicationNotStartedExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The dashboard login URL is not available in publish mode.. + /// + internal static string DashboardLoginUrlPublishModeExceptionMessage { + get { + return ResourceManager.GetString("DashboardLoginUrlPublishModeExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The dashboard login URL is not available.. + /// + internal static string DashboardLoginUrlUnavailableExceptionMessage { + get { + return ResourceManager.GetString("DashboardLoginUrlUnavailableExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dashboard testing is not supported in publish mode.. + /// + internal static string DashboardTestingPublishModeExceptionMessage { + get { + return ResourceManager.GetString("DashboardTestingPublishModeExceptionMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Endpoint '{0}' for resource '{1}' not found.. /// diff --git a/src/Aspire.Hosting.Testing/Properties/Resources.resx b/src/Aspire.Hosting.Testing/Properties/Resources.resx index ec59974c7d9..e675f85d789 100644 --- a/src/Aspire.Hosting.Testing/Properties/Resources.resx +++ b/src/Aspire.Hosting.Testing/Properties/Resources.resx @@ -120,6 +120,24 @@ The application must be started before resolving endpoints or connection strings. + + The dashboard is not enabled for this application. + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + The application must be started before retrieving the dashboard login URL. + + + The dashboard login URL is not available in publish mode. + + + The dashboard login URL is not available. + + + Dashboard testing is not supported in publish mode. + Endpoint '{0}' for resource '{1}' not found. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf index 61efe72e431..6f912830b86 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf @@ -7,6 +7,36 @@ Aplikace musí být spuštěna před překladem koncových bodů nebo připojovacích řetězců. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. Koncový bod {0} pro prostředek {1} se nenašel. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf index e2c744e2440..a58967c1e45 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf @@ -7,6 +7,36 @@ Die Anwendung muss gestartet werden, bevor Endpunkte oder Verbindungszeichenfolgen aufgelöst werden können. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. Der Endpunkt "{0}" für die Ressource "{1}" wurde nicht gefunden. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf index 1804c647edf..5ffc8bb5672 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf @@ -7,6 +7,36 @@ La aplicación debe iniciarse antes de resolver los puntos de conexión o las cadenas de conexión. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. No se encuentra el de punto de conexión '{0}' para el recurso '{1}'. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf index 313b33dc41c..a588096c6f6 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf @@ -7,6 +7,36 @@ L’application doit être démarrée avant de résoudre les points de terminaison ou les chaînes de connexion. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. Le point de terminaison « {0} » de la ressource « {1} » est introuvable. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf index e71c7e91baa..e4f07a2ca74 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf @@ -7,6 +7,36 @@ È necessario avviare l'applicazione prima di risolvere endpoint o stringhe di connessione. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. L'endpoint '{0}' per la risorsa '{1}' non è stato trovato. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf index 6b22c3d35d7..1fdf9f9c720 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf @@ -7,6 +7,36 @@ エンドポイントまたは接続文字列を解決する前に、アプリケーションを起動する必要があります。 + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. リソース '{1}' のエンドポイント '{0}' が見つかりません。 diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf index d4b32f6bf43..86815c0d358 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf @@ -7,6 +7,36 @@ 엔드포인트 또는 연결 문자열을 확인하려면 먼저 애플리케이션을 시작해야 합니다. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. '{1}' 리소스에 대한 '{0}' 엔드포인트를 찾을 수 없습니다. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf index 4af037877ef..8aee15257c3 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf @@ -7,6 +7,36 @@ Aplikacja musi zostać uruchomiona przed rozpoznaniem punktów końcowych lub parametrów połączenia. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. Nie znaleziono punktu końcowego „{0}” dla zasobu „{1}”. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf index 4bae1d1e624..4c55116c847 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf @@ -7,6 +7,36 @@ O aplicativo deve ser iniciado antes de resolver pontos de extremidade ou cadeias de conexão. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. O ponto de extremidade “{0}” para o recurso “{1}” não foi encontrado. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf index 9df64bff376..dbd9a9263e6 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf @@ -7,6 +7,36 @@ Перед разрешением конечных точек или строк подключения необходимо запустить приложение. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. Конечная точка "{0}" для ресурса "{1}" не найдена. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf index 3e26aac0c56..efa071ff0d7 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf @@ -7,6 +7,36 @@ Uç noktalarını veya bağlantı dizelerini çözümlemeden önce uygulama başlatmalıdır. + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. '{1}' kaynağı için '{0}' uç noktası bulunamadı. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf index 162d75096bb..4f279b0e61b 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf @@ -7,6 +7,36 @@ 必须先启动应用程序,然后才能解析终结点或连接字符串。 + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. 找不到资源“{1}”的终结点“{0}”。 diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf index bac7f8875b3..87946aba067 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf @@ -7,6 +7,36 @@ 必須先啟動應用程式,才能解析端點或連接字串。 + + The dashboard is not enabled for this application. + The dashboard is not enabled for this application. + + + + The dashboard login URL is not available because anonymous dashboard access is enabled. + The dashboard login URL is not available because anonymous dashboard access is enabled. + + + + The application must be started before retrieving the dashboard login URL. + The application must be started before retrieving the dashboard login URL. + + + + The dashboard login URL is not available in publish mode. + The dashboard login URL is not available in publish mode. + + + + The dashboard login URL is not available. + The dashboard login URL is not available. + + + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. + + Endpoint '{0}' for resource '{1}' not found. 找不到資源 '{1}' 的端點 '{0}'。 diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index f257eba7b61..036918518f3 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -137,6 +137,11 @@ + diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs index e732629efcf..bbbcbd499a9 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using Xunit; +using TestingResources = Aspire.Hosting.Testing.Properties.Resources; namespace Aspire.Hosting.Testing.Tests; @@ -133,7 +134,7 @@ public async Task GetDashboardLoginUrlAsyncThrowsWhenDashboardIsDisabled() var exception = await Assert.ThrowsAsync( () => app.GetDashboardLoginUrlAsync(default)); - Assert.Equal("The dashboard is not enabled for this application.", exception.Message); + Assert.Equal(TestingResources.DashboardDisabledExceptionMessage, exception.Message); } [Fact] @@ -148,9 +149,7 @@ public async Task GetDashboardLoginUrlAsyncThrowsWhenDashboardAllowsAnonymousAcc var exception = await Assert.ThrowsAsync( () => app.GetDashboardLoginUrlAsync(default)); - Assert.Equal( - "The dashboard login URL is not available because anonymous dashboard access is enabled.", - exception.Message); + Assert.Equal(TestingResources.DashboardLoginUrlAnonymousExceptionMessage, exception.Message); } [Fact] @@ -162,7 +161,7 @@ public async Task GetDashboardLoginUrlAsyncThrowsBeforeApplicationStarts() var exception = await Assert.ThrowsAsync( () => app.GetDashboardLoginUrlAsync(default)); - Assert.Equal("The application must be started before retrieving the dashboard login URL.", exception.Message); + Assert.Equal(TestingResources.DashboardLoginUrlApplicationNotStartedExceptionMessage, exception.Message); } [Fact] @@ -210,15 +209,13 @@ await Assert.ThrowsAsync( [Fact] public async Task GetDashboardLoginUrlAsyncThrowsInPublishMode() { - var builder = DistributedApplicationTestingBuilder.Create( - ["--publisher", "manifest"], - (options, _) => options.DisableDashboard = false); + var builder = DistributedApplicationTestingBuilder.Create(["--publisher", "manifest"]); await using var app = await builder.BuildAsync(); var exception = await Assert.ThrowsAsync( () => app.GetDashboardLoginUrlAsync(default)); - Assert.Equal("The dashboard login URL is not available in publish mode.", exception.Message); + Assert.Equal(TestingResources.DashboardLoginUrlPublishModeExceptionMessage, exception.Message); } [Fact] diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs index 1106c3a32f6..c34861a6bcb 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Xunit; +using TestingResources = Aspire.Hosting.Testing.Properties.Resources; namespace Aspire.Hosting.Testing.Tests; @@ -20,6 +21,8 @@ public class DashboardTestingBuilderTests private const string DashboardUnsecuredAllowAnonymous = "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"; private const string InteractivityEnabled = "ASPIRE_INTERACTIVITY_ENABLED"; private const string ResourceServiceEndpointUrl = "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"; + private const string DashboardFrontendBrowserToken = "ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN"; + private const string AppHostBrowserToken = "AppHost:BrowserToken"; [Fact] public void DashboardIsDisabledByDefault() @@ -41,13 +44,7 @@ public async Task DashboardCanBeEnabledAtBuilderCreation(CreationSurface creatio var builder = await CreateDashboardBuilderAsync(creationSurface, []); Assert.Single(builder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); - Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[AspNetCoreUrls]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpGrpcEndpointUrl]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpHttpEndpointUrl]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); - Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); - Assert.Equal("false", builder.Configuration[InteractivityEnabled]); + AssertDashboardTestingDefaults(builder); await using var app = await builder.BuildAsync(); } @@ -71,13 +68,7 @@ public async Task DashboardTestingDefaultsOverrideConfigurationPresentDuringCrea await using var builder = await CreateDashboardBuilderAsync(creationSurface, args); - Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[AspNetCoreUrls]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpGrpcEndpointUrl]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpHttpEndpointUrl]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); - Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); - Assert.Equal("false", builder.Configuration[InteractivityEnabled]); + AssertDashboardTestingDefaults(builder); Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration["AppHost:ResourceService:AuthMode"]); } @@ -90,13 +81,31 @@ public async Task DashboardTestingDefaultsOverrideAppHostConfiguration(CreationS creationSurface, ["--override-dashboard-testing-defaults"]); - Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[AspNetCoreUrls]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpGrpcEndpointUrl]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[DashboardOtlpHttpEndpointUrl]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); - Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); - Assert.Equal("false", builder.Configuration[InteractivityEnabled]); + AssertDashboardTestingDefaults(builder); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingGeneratesAFreshBrowserTokenPerApplication(CreationSurface creationSurface) + { + // A token shared by every application under test is barely better than anonymous access, and this is + // exactly the shape an ambient ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN on a CI agent would take. + const string SharedToken = "shared-browser-token"; + string[] args = [$"--{DashboardFrontendBrowserToken}={SharedToken}"]; + + await using var first = await CreateDashboardBuilderAsync(creationSurface, args); + await using var second = await CreateDashboardBuilderAsync(creationSurface, args); + + var firstToken = first.Configuration[AppHostBrowserToken]; + var secondToken = second.Configuration[AppHostBrowserToken]; + + Assert.NotEmpty(firstToken!); + Assert.NotEmpty(secondToken!); + Assert.NotEqual(SharedToken, firstToken); + Assert.NotEqual(SharedToken, secondToken); + Assert.NotEqual(firstToken, secondToken); } [Fact] @@ -112,6 +121,23 @@ public async Task DashboardTestingDefaultsAreNonInteractiveAndFailFast() app.Services.GetRequiredService>().Value.DefaultWaitBehavior); } + [Fact] + public async Task DashboardTestingDefaultWaitBehaviorCanBeOverriddenThroughOptions() + { + // Fail-fast is right for an unattended run, but when the dashboard is up to be looked at, waiting keeps the + // stuck resource alive long enough to inspect instead of tearing the application down. + var options = CreateDashboardOptions(); + options.DefaultWaitBehavior = WaitBehavior.WaitOnResourceUnavailable; + + var builder = DistributedApplicationTestingBuilder.Create(options, []); + + await using var app = await builder.BuildAsync(); + + Assert.Equal( + WaitBehavior.WaitOnResourceUnavailable, + app.Services.GetRequiredService>().Value.DefaultWaitBehavior); + } + [Fact] public async Task DashboardTestingDefaultsCanBeOverriddenAfterCreation() { @@ -147,6 +173,9 @@ await Assert.ThrowsAsync( [Fact] public async Task ExistingDefaultCallsRemainUnambiguous() { + // `default` has to keep binding to the pre-existing params string[] and CancellationToken overloads + // rather than to the new options overloads, otherwise adding those overloads is a source-breaking change. + // The compiler proves the binding; these assertions prove the bound overloads still behave as before. Assert.Throws(() => DistributedApplicationTestingBuilder.Create(default!)); await using var genericBuilder = @@ -154,20 +183,23 @@ public async Task ExistingDefaultCallsRemainUnambiguous() await using var typeBuilder = await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), default); - Func> genericOptionsCall = () => - DistributedApplicationTestingBuilder.CreateAsync( + Assert.Null(genericBuilder.Services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DashboardServiceHost))); + Assert.Null(typeBuilder.Services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(DashboardServiceHost))); + + await using var genericOptionsBuilder = + await DistributedApplicationTestingBuilder.CreateAsync( CreateDashboardOptions(), [], default); - Func> typeOptionsCall = () => - DistributedApplicationTestingBuilder.CreateAsync( + await using var typeOptionsBuilder = + await DistributedApplicationTestingBuilder.CreateAsync( typeof(Projects.TestingAppHost1_AppHost), CreateDashboardOptions(), [], default); - Assert.NotNull(genericOptionsCall); - Assert.NotNull(typeOptionsCall); + Assert.Single(genericOptionsBuilder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); + Assert.Single(typeOptionsBuilder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); } [Fact] @@ -196,7 +228,7 @@ await Assert.ThrowsAsync( } [Fact] - public async Task BuildAsyncCancellationAfterReleaseDisposesBuiltApplication() + public async Task BuildAsyncCancellationAfterReleaseReturnsPromptlyAndDisposesBuiltApplication() { var probe = TestingAppHostBuildProbe.Create(); var builder = @@ -210,10 +242,50 @@ public async Task BuildAsyncCancellationAfterReleaseDisposesBuiltApplication() Assert.False(buildTask.IsCompleted); cancellationTokenSource.Cancel(); - await Task.Delay(50); + await Assert.ThrowsAnyAsync(() => buildTask.DefaultTimeout()); + + // The AppHost is still blocked inside Build(), so observing cancellation here proves BuildAsync + // returned without waiting for the application it already released. + Assert.False(probe.ApplicationDisposed.IsCompleted); + + probe.ContinueBuilding(); + + // Promptness is already proven by the assertion above. Reclaiming the late application goes through + // DistributedApplicationFactory.DisposeAsync, which first waits for the released AppHost entry point to + // exit under the host's shutdown timeout, so this leg needs a budget larger than DefaultTimeout's 5s. + await probe.ApplicationDisposed.WaitAsync(TimeSpan.FromSeconds(60)); + } + finally + { + probe.ContinueBuilding(); + await builder.DisposeAsync(); + probe.Dispose(); + } + } + + [Fact] + public async Task BuildAsyncCancellationFollowedByDisposeStillDisposesLateApplication() + { + var probe = TestingAppHostBuildProbe.Create(); + var builder = + await DistributedApplicationTestingBuilder.CreateAsync( + [$"--block-apphost-build={probe.Id}"]); + try + { + using var cancellationTokenSource = new CancellationTokenSource(); + var buildTask = builder.BuildAsync(cancellationTokenSource.Token); + await probe.BuildEntered.DefaultTimeout(); + + cancellationTokenSource.Cancel(); + await Assert.ThrowsAnyAsync(() => buildTask.DefaultTimeout()); + + // Disposing before the AppHost finishes building is the ordinary `await using` sequence. The + // application arrives after disposal has already claimed the factory, so nothing the caller holds + // can tear it down; the factory has to reclaim it. + await builder.DisposeAsync().DefaultTimeout(); + probe.ContinueBuilding(); - await Assert.ThrowsAnyAsync(() => buildTask); await probe.ApplicationDisposed.DefaultTimeout(); } finally @@ -224,13 +296,42 @@ public async Task BuildAsyncCancellationAfterReleaseDisposesBuiltApplication() } } + [Fact] + public async Task BuildAsyncCancellationPreservesOperationCanceledExceptionWhenAppHostLaterFails() + { + var probe = TestingAppHostBuildProbe.Create(); + var builder = + await DistributedApplicationTestingBuilder.CreateAsync( + [$"--block-apphost-build={probe.Id}", "--crash-after-build"]); + try + { + using var cancellationTokenSource = new CancellationTokenSource(); + var buildTask = builder.BuildAsync(cancellationTokenSource.Token); + await probe.BuildEntered.DefaultTimeout(); + + cancellationTokenSource.Cancel(); + probe.ContinueBuilding(); + await probe.EntryPointFailure.DefaultTimeout(); + + await Assert.ThrowsAnyAsync(() => buildTask); + } + finally + { + probe.ContinueBuilding(); + await builder.DisposeAsync(); + probe.Dispose(); + } + } + [Fact] public async Task BuildAsyncAfterBuilderDisposedThrowsObjectDisposedException() { var builder = await DistributedApplicationTestingBuilder.CreateAsync(); await builder.DisposeAsync(); - await Assert.ThrowsAsync(() => builder.BuildAsync()); + var exception = await Assert.ThrowsAsync(() => builder.BuildAsync()); + + Assert.Equal(nameof(IDistributedApplicationTestingBuilder), exception.ObjectName); } [Theory] @@ -248,7 +349,24 @@ public async Task DashboardTestingIsRejectedInPublishMode( var exception = await Assert.ThrowsAsync( () => CreatePublishBuilderAsync(creationSurface, [argumentName, argumentValue])); - Assert.Equal("Dashboard testing is not supported in publish mode.", exception.Message); + Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); + } + + [Fact] + public async Task DashboardTestingIsRejectedInPublishModeWhenEnabledThroughConfigureBuilder() + { + // DistributedApplicationOptions.DisableDashboard = false is the older spelling of "run the dashboard", and + // it has to reach the same rejection as the EnableDashboard option rather than silently skipping it. + var exception = await Assert.ThrowsAsync(async () => + { + var builder = DistributedApplicationTestingBuilder.CreateAsync( + ["--publisher", "manifest"], + (options, _) => options.DisableDashboard = false); + + await using var created = await builder; + }); + + Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); } private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() @@ -259,6 +377,27 @@ private static DistributedApplicationTestingBuilderOptions CreateDashboardOption }; } + private static void AssertDashboardTestingDefaults(IDistributedApplicationTestingBuilder builder) + { + Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); + + // Blank is how the product spells "assign me a free port", so these must stay empty rather than + // carrying an explicit :0, which would be a literal fixed port. + Assert.Equal(string.Empty, builder.Configuration[AspNetCoreUrls]); + Assert.Equal(string.Empty, builder.Configuration[DashboardOtlpGrpcEndpointUrl]); + Assert.Equal(string.Empty, builder.Configuration[DashboardOtlpHttpEndpointUrl]); + + Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); + Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); + Assert.Equal("false", builder.Configuration[InteractivityEnabled]); + + // The token has to survive all the way into AppHost:BrowserToken, which is the key the dashboard + // actually validates against and the one GetDashboardLoginUrlAsync hands back. + var browserToken = builder.Configuration[DashboardFrontendBrowserToken]; + Assert.NotEmpty(browserToken!); + Assert.Equal(browserToken, builder.Configuration[AppHostBrowserToken]); + } + private static async Task CreateDashboardBuilderAsync( CreationSurface creationSurface, string[] args) diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index 5516ccf2e7a..892878e6e7a 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -72,6 +72,12 @@ if (args.Contains("--crash-after-build")) { + if (buildProbeArgument is not null) + { + var buildProbeId = buildProbeArgument["--block-apphost-build=".Length..]; + TestingAppHostBuildProbe.SignalEntryPointFailure(buildProbeId); + } + throw new InvalidOperationException("Crashing: after-build."); } diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs index d68277bcfd5..10f874eb924 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs @@ -34,6 +34,16 @@ internal static void Configure(IDistributedApplicationBuilder builder, string id state.Arm(); } + internal static void SignalEntryPointFailure(string id) + { + if (!s_probes.TryGetValue(id, out var state)) + { + throw new InvalidOperationException($"Build probe '{id}' was not registered."); + } + + state.EntryPointFailure.TrySetResult(); + } + public sealed class Probe : IDisposable { private readonly ProbeState _state; @@ -48,6 +58,8 @@ internal Probe(string id, ProbeState state) public Task BuildEntered => _state.BuildEntered.Task; + public Task EntryPointFailure => _state.EntryPointFailure.Task; + public Task ApplicationDisposed => _state.ApplicationDisposed.Task; public void ContinueBuilding() @@ -71,6 +83,8 @@ internal sealed class ProbeState public TaskCompletionSource ContinueBuilding { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource EntryPointFailure { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ApplicationDisposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public void Arm() From 8ae0f6df042681cccad3fbaf6f1911a5b684db68 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 13:29:31 -0400 Subject: [PATCH 04/21] Fix dashboard testing regressions found by CI Restrict the hardened dashboard testing defaults to the explicit DistributedApplicationTestingBuilderOptions.EnableDashboard opt-in. Treating DistributedApplicationOptions.DisableDashboard = false as an equivalent spelling changed behavior for every caller already using it: - The args rebuild read hostBuilderOptions.Args, which the factory has already populated, so arguments a configureBuilder callback assigned to applicationOptions.Args were dropped. DashboardIsNotAddedInPublishMode lost "--publisher manifest", ran in run mode, and saw a dashboard resource it asserted was absent. - The appended dashboard settings overrode the caller's own configuration. GetDashboardUrlsAsync_ReturnsBaseUrl_WhenDashboardAllowsAnonymousAccess asked for anonymous access and got a browser token instead. Also merge caller args instead of choosing one array, so the new opt-in appends to whatever the callback left in place. Build Aspire.Dashboard from Aspire.Hosting.Testing.Tests. The tests that start a real dashboard resolve it from the repo-wide AspireDashboardDir, which was empty in the Hosting.Testing CI leg, failing all eight of them. Add ConcurrentDisposeAsyncRunsTeardownOnce, covering the interlocked disposal claim in DistributedApplicationFactory.DisposeAsync. With the claim reverted to a cancellation-token read the test fails consistently; with the claim in place it passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributedApplicationTestingBuilder.cs | 33 +++-- .../Aspire.Hosting.Testing.Tests.csproj | 5 + .../DashboardTestingBuilderTests.cs | 121 ++++++++++++++++-- 3 files changed, 137 insertions(+), 22 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index e3b474bf5e9..9a1fe2fd9f2 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -279,14 +279,16 @@ internal static IDistributedApplicationTestingBuilder Create( Assembly appHostAssembly) => CreateCore(args, testingOptions: null, configureBuilder, appHostAssembly); - private static bool IsDashboardTestingEnabled( - DistributedApplicationOptions applicationOptions, - DistributedApplicationTestingBuilderOptions? testingOptions) + private static bool IsDashboardTestingEnabled(DistributedApplicationTestingBuilderOptions? testingOptions) { - // Setting DistributedApplicationOptions.DisableDashboard = false through the configureBuilder callback was - // already the supported way to run a dashboard from a test, so treat it as equivalent to the option. Both - // spellings must converge here or only one of them gets the hardened testing defaults below. - return testingOptions?.EnableDashboard == true || !applicationOptions.DisableDashboard; + // 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( @@ -295,7 +297,7 @@ private static void ConfigureDashboardTesting( DistributedApplicationTestingBuilderOptions? testingOptions, out DashboardTestingState dashboardTestingState) { - if (!IsDashboardTestingEnabled(applicationOptions, testingOptions)) + if (!IsDashboardTestingEnabled(testingOptions)) { dashboardTestingState = default; return; @@ -317,9 +319,17 @@ private static void ConfigureDashboardTesting( // 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. + // + // 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 = [ - .. (hostBuilderOptions.Args ?? applicationOptions.Args ?? []), + .. existingArgs, $"{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=false", $"{KnownConfigNames.DashboardFrontendBrowserToken}={TokenGenerator.GenerateToken()}" ]; @@ -421,9 +431,8 @@ private sealed class SuspendingDistributedApplicationFactory( private int _buildingContinuationState; - // Resolved while the builder is being constructed, from the effective DistributedApplicationOptions rather - // than the caller's options alone, so an AppHost that enables the dashboard through configureBuilder gets the - // same hardened testing defaults. + // 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 CreateBuilderAsync(CancellationToken cancellationToken) diff --git a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj index 3a71b518c12..616c2ca5ad3 100644 --- a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj +++ b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj @@ -36,6 +36,11 @@ + + + diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs index c34861a6bcb..9edeeb0dec9 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -3,10 +3,12 @@ #pragma warning disable ASPIREINTERACTION001 +using System.Runtime.CompilerServices; using Aspire.Hosting.Dashboard; using Aspire.Hosting.Dcp; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Xunit; using TestingResources = Aspire.Hosting.Testing.Properties.Resources; @@ -353,20 +355,93 @@ public async Task DashboardTestingIsRejectedInPublishMode( } [Fact] - public async Task DashboardTestingIsRejectedInPublishModeWhenEnabledThroughConfigureBuilder() + public async Task DashboardEnabledThroughConfigureBuilderKeepsCallerConfiguration() { - // DistributedApplicationOptions.DisableDashboard = false is the older spelling of "run the dashboard", and - // it has to reach the same rejection as the EnableDashboard option rather than silently skipping it. - var exception = await Assert.ThrowsAsync(async () => + // DistributedApplicationOptions.DisableDashboard = false is the older spelling of "run the dashboard" and it + // predates DistributedApplicationTestingBuilderOptions. It is not the hardened dashboard testing mode, so + // configuration the caller supplied at creation has to survive rather than being rewritten underneath them. + await using var builder = await DistributedApplicationTestingBuilder.CreateAsync( + [$"--{DashboardUnsecuredAllowAnonymous}=true", $"--{AspNetCoreUrls}=http://127.0.0.1:12345"], + (options, _) => options.DisableDashboard = false); + + Assert.Equal("true", builder.Configuration[DashboardUnsecuredAllowAnonymous]); + Assert.Equal("http://127.0.0.1:12345", builder.Configuration[AspNetCoreUrls]); + } + + [Fact] + public async Task DashboardEnabledThroughConfigureBuilderIsNotRejectedInPublishMode() + { + // Publish mode never adds a dashboard resource, so the older spelling has always been ignored there. + // Rejecting it would break existing callers that publish with DisableDashboard = false, and dropping their + // arguments while resolving dashboard settings would silently turn a publish run into a run-mode run. + await using var builder = await DistributedApplicationTestingBuilder.CreateAsync( + ["--publisher", "manifest"], + (options, _) => options.DisableDashboard = false); + + Assert.True(builder.ExecutionContext.IsPublishMode); + } + + [Fact] + public async Task ConcurrentDisposeAsyncRunsTeardownOnce() + { + // DistributedApplicationFactory.DisposeAsync claims disposal with an interlocked exchange. A guard that only + // reads the disposal cancellation token lets concurrent disposers all pass, because that token is cancelled + // after the guard, and each of them then stops and disposes the same application. Racing several disposers + // is the only way to exercise the claim; a sequential second call is filtered by either implementation. + var hostStopCount = new StrongBox(); + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + CountHostStops(builder, hostStopCount); + + await builder.BuildAsync().DefaultTimeout(); + + // The window between reading the guard and cancelling the disposal token is only a few instructions wide, + // so the disposers run on dedicated threads released by a barrier. Thread pool continuations arrive too far + // apart to land in that window reliably. + const int DisposerCount = 8; + var disposals = new Task[DisposerCount]; + var threads = new Thread[DisposerCount]; + using var start = new Barrier(DisposerCount); + for (var i = 0; i < DisposerCount; i++) { - var builder = DistributedApplicationTestingBuilder.CreateAsync( - ["--publisher", "manifest"], - (options, _) => options.DisableDashboard = false); + var index = i; + threads[index] = new Thread(() => + { + start.SignalAndWait(); + + // DisposeAsync runs synchronously on this thread up to its first suspension point, which is past + // the guard, so the returned task is only awaited once every disposer has been through it. + disposals[index] = builder.DisposeAsync().AsTask(); + }) + { + IsBackground = true, + Name = $"Disposer-{index}" + }; + threads[index].Start(); + } - await using var created = await builder; - }); + foreach (var thread in threads) + { + Assert.True(thread.Join(TimeSpan.FromSeconds(60))); + } - Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); + // A disposer that ran teardown against an application another disposer had already disposed surfaces its + // exception here. Disposal waits for the released AppHost entry point to exit under the host's shutdown + // timeout, so this needs a budget larger than DefaultTimeout's 5s. + await Task.WhenAll(disposals).WaitAsync(TimeSpan.FromSeconds(60)); + + Assert.Equal(1, hostStopCount.Value); + } + + private static void CountHostStops(IDistributedApplicationTestingBuilder builder, StrongBox stopCount) + { + // The testing factory registers the IHost that HostApplicationBuilder.Build() resolves and that + // DistributedApplication.StopAsync() delegates to. Wrapping the last registration counts every teardown pass + // through DistributedApplicationFactory.DisposeAsync, including passes an unclaimed guard would allow. + var hostDescriptor = builder.Services.Last( + descriptor => descriptor.ServiceType == typeof(IHost) && descriptor.ServiceKey is null); + builder.Services.Remove(hostDescriptor); + builder.Services.AddSingleton( + sp => new StopCountingHost((IHost)hostDescriptor.ImplementationFactory!(sp), stopCount)); } private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() @@ -434,4 +509,30 @@ public enum CreationSurface Type, AdHoc } + + private sealed class StopCountingHost(IHost innerHost, StrongBox stopCount) : IHost, IAsyncDisposable + { + public IServiceProvider Services => innerHost.Services; + + public void Dispose() => innerHost.Dispose(); + + public async ValueTask DisposeAsync() + { + if (innerHost is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + return; + } + + innerHost.Dispose(); + } + + public Task StartAsync(CancellationToken cancellationToken = default) => innerHost.StartAsync(cancellationToken); + + public Task StopAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref stopCount.Value); + return innerHost.StopAsync(cancellationToken); + } + } } From 10b63374b604c56b04f051f90404bb7ebd44f584 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 15:18:19 -0400 Subject: [PATCH 05/21] Pin the AppHost browser token boundary with a test Review asked for the generated browser token to be restored after the AppHost configures itself, so an AppHost clearing AppHost:BrowserToken could not downgrade the dashboard to Unsecured authentication. Pinning it onto DashboardOptions works, but it removes the only way to reach the anonymous dashboard path once EnableDashboard has appended its own arguments. Two tests in this PR rely on that escape hatch through the returned builder, and both fail with the token pinned: GetDashboardLoginUrlAsyncThrowsWhenDashboardAllowsAnonymousAccess and CanonicalDashboardLoginUrlEscapesBrowserToken. The state is also not silent. DashboardUrlsHelper reports HasBrowserToken false and GetDashboardLoginUrlAsync throws rather than returning an unauthenticated URL. Cover the boundary instead, so neither side of it can drift unnoticed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DashboardTestingBuilderTests.cs | 21 +++++++++++++++++++ .../TestingAppHost1.AppHost/Program.cs | 8 +++++++ 2 files changed, 29 insertions(+) diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs index 9edeeb0dec9..fc19c90e089 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -381,6 +381,27 @@ public async Task DashboardEnabledThroughConfigureBuilderIsNotRejectedInPublishM Assert.True(builder.ExecutionContext.IsPublishMode); } + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingDoesNotPinTheBrowserTokenAgainstTheAppHost(CreationSurface creationSurface) + { + // Anonymous access is settled while the builder is constructed and cannot be taken back, but the browser + // token is only read out of AppHost:BrowserToken when DashboardOptions is first resolved, so an AppHost + // that clears the key still gets a dashboard with no credential. That asymmetry is deliberate rather than + // a hole: clearing this key is the only way to reach the anonymous dashboard path once EnableDashboard has + // appended its own arguments, the same escape hatch a test uses through the returned builder, and it does + // not fail silently because GetDashboardLoginUrlAsync then reports anonymous access instead of handing + // back an unauthenticated URL. Re-pinning the token here would take the escape hatch away from both. + await using var builder = await CreateDashboardBuilderAsync( + creationSurface, + ["--clear-apphost-browser-token"]); + await using var app = await builder.BuildAsync(); + + var dashboardOptions = app.Services.GetRequiredService>().Value; + Assert.True(string.IsNullOrEmpty(dashboardOptions.DashboardToken)); + } + [Fact] public async Task ConcurrentDisposeAsyncRunsTeardownOnce() { diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index 892878e6e7a..e65eaef2afe 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -28,6 +28,14 @@ builder.Configuration["ASPIRE_INTERACTIVITY_ENABLED"] = "true"; } +if (args.Contains("--clear-apphost-browser-token")) +{ + // Clearing the frozen token is the one dashboard setting an AppHost can still change after the testing + // builder has hardened things, because DashboardOptions does not read this key until the application starts. + // It is kept separate from --override-dashboard-testing-defaults so that flag keeps asserting on a token. + builder.Configuration["AppHost:BrowserToken"] = ""; +} + builder.Configuration["ConnectionStrings:cs"] = "testconnection"; builder.AddConnectionString("cs"); From d776ba65144299add7b5408735697ae93a6bd36b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 16:25:31 -0400 Subject: [PATCH 06/21] Do not dispose an application a concurrent build still owns After a post-release cancellation, ReclaimApplicationInBackground awaited the shared application task and then disposed the factory unconditionally. The continuation state stayed ContinuationReleased, so a concurrent or retried BuildAsync kept waiting on that same task. When the application arrived both callers received it and the background continuation tore it down underneath the surviving one. Guard the continuation state and the outstanding build count together, so only the last caller to cancel reclaims the application, and reject later builds once it has been claimed rather than handing back an instance that is being disposed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributedApplicationTestingBuilder.cs | 88 ++++++++++++++++--- .../DashboardTestingBuilderTests.cs | 75 ++++++++++++++++ 2 files changed, 152 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 9a1fe2fd9f2..d01eca7b869 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -423,14 +423,24 @@ private sealed class SuspendingDistributedApplicationFactory( { private readonly SemaphoreSlim _continueBuilding = new(0); + // Guards the pair of (continuation state, outstanding build count). They have to move together: a caller + // may only start waiting for the application while no other caller has abandoned it, and an abandoning + // caller may only claim it once no other caller is still waiting. Interlocked operations on the two fields + // cannot express that, and nothing is awaited while the lock is held. + private readonly object _buildStateLock = new(); + // Tracks whether the suspended AppHost entry point has been released, and why. Disposal must win over a // concurrent BuildAsync so a rejected builder cannot continue into Build() after the factory is gone. private const int ContinuationSuspended = 0; private const int ContinuationReleased = 1; - private const int ContinuationDisposed = 2; + private const int ContinuationAbandoned = 2; + private const int ContinuationDisposed = 3; private int _buildingContinuationState; + // Number of BuildAsync calls that have released the AppHost and are still waiting for the application. + private int _outstandingBuilds; + // 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; @@ -465,33 +475,75 @@ public async Task BuildAsync(CancellationToken cancellat { cancellationToken.ThrowIfCancellationRequested(); - var previousState = Interlocked.CompareExchange(ref _buildingContinuationState, ContinuationReleased, ContinuationSuspended); - if (previousState == ContinuationDisposed) + bool releaseAppHost; + lock (_buildStateLock) { - throw CreateDisposedException(); + switch (_buildingContinuationState) + { + case ContinuationDisposed: + throw CreateDisposedException(); + case ContinuationAbandoned: + throw CreateAbandonedException(); + } + + releaseAppHost = _buildingContinuationState == ContinuationSuspended; + Volatile.Write(ref _buildingContinuationState, ContinuationReleased); + _outstandingBuilds++; } - if (previousState == ContinuationSuspended) + if (releaseAppHost) { _continueBuilding.Release(); } + var canceled = false; try { return await ResolveApplicationAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // Once the AppHost has been released, it can still finish building after the caller stops waiting. - // Cancellation must return to the caller promptly, so reclaim that application on a background - // continuation rather than blocking here until an AppHost that may never finish completes. - ReclaimApplicationInBackground(); + canceled = true; throw; } catch (OperationCanceledException) when (Volatile.Read(ref _buildingContinuationState) == ContinuationDisposed) { throw CreateDisposedException(); } + finally + { + if (TryAbandonApplication(canceled)) + { + // Once the AppHost has been released, it can still finish building after the caller stops waiting. + // Cancellation must return to the caller promptly, so reclaim that application on a background + // continuation rather than blocking here until an AppHost that may never finish completes. + ReclaimApplicationInBackground(); + } + } + } + + /// + /// Records that a call stopped waiting for the application, and reports whether it + /// abandoned it, meaning no caller is left to take ownership of an application that still arrives. + /// + private bool TryAbandonApplication(bool canceled) + { + lock (_buildStateLock) + { + _outstandingBuilds--; + + // A caller that is still waiting takes ownership of the application and disposes it through the + // builder, so only the last caller to cancel may reclaim it. Disposal already owns the teardown. + if (!canceled || _outstandingBuilds > 0 || _buildingContinuationState != ContinuationReleased) + { + return false; + } + + // The AppHost entry point produces exactly one application, and it is now spoken for by the + // background reclaim, so later builds have to be rejected rather than handed a disposed instance. + Volatile.Write(ref _buildingContinuationState, ContinuationAbandoned); + return true; + } } /// @@ -529,6 +581,14 @@ private static ObjectDisposedException CreateDisposedException() "The testing builder was disposed before the application was built."); } + private static InvalidOperationException CreateAbandonedException() + { + return new InvalidOperationException( + "The application was abandoned because the BuildAsync call that released the AppHost was canceled, " + + "and it is being disposed. An AppHost entry point builds a single application, so building again " + + "requires a new testing builder."); + } + public override async ValueTask DisposeAsync() { PrepareForDisposal(); @@ -543,8 +603,14 @@ public override void Dispose() private void PrepareForDisposal() { - var previousState = Interlocked.Exchange(ref _buildingContinuationState, ContinuationDisposed); - if (previousState == ContinuationSuspended) + bool releaseAppHost; + lock (_buildStateLock) + { + releaseAppHost = _buildingContinuationState == ContinuationSuspended; + Volatile.Write(ref _buildingContinuationState, ContinuationDisposed); + } + + if (releaseAppHost) { // Abort on the AppHost entry-point thread instead of allowing a rejected or canceled // builder to continue into Build() after the factory has already been disposed. diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs index fc19c90e089..5488a999c92 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -336,6 +336,81 @@ public async Task BuildAsyncAfterBuilderDisposedThrowsObjectDisposedException() Assert.Equal(nameof(IDistributedApplicationTestingBuilder), exception.ObjectName); } + [Fact] + public async Task BuildAsyncAfterCancellationAbandonedTheApplicationIsRejected() + { + var probe = TestingAppHostBuildProbe.Create(); + var builder = + await DistributedApplicationTestingBuilder.CreateAsync( + [$"--block-apphost-build={probe.Id}"]); + try + { + using var cancellationTokenSource = new CancellationTokenSource(); + var buildTask = builder.BuildAsync(cancellationTokenSource.Token); + await probe.BuildEntered.DefaultTimeout(); + + cancellationTokenSource.Cancel(); + await Assert.ThrowsAnyAsync(() => buildTask.DefaultTimeout()); + + // No caller was left waiting, so the application the AppHost is still building belongs to the + // background reclaim. Handing it to a retry would give that caller an application being disposed. + // The budget only bounds a regression: rejection happens before this token can be observed. + using var retryTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var exception = await Assert.ThrowsAsync( + () => builder.BuildAsync(retryTimeout.Token)); + + Assert.Equal( + "The application was abandoned because the BuildAsync call that released the AppHost was canceled, " + + "and it is being disposed. An AppHost entry point builds a single application, so building again " + + "requires a new testing builder.", + exception.Message); + } + finally + { + probe.ContinueBuilding(); + await builder.DisposeAsync(); + probe.Dispose(); + } + } + + [Fact] + public async Task BuildAsyncCancellationLeavesTheApplicationToAConcurrentBuild() + { + var probe = TestingAppHostBuildProbe.Create(); + var builder = + await DistributedApplicationTestingBuilder.CreateAsync( + [$"--block-apphost-build={probe.Id}"]); + try + { + using var cancellationTokenSource = new CancellationTokenSource(); + var canceledBuildTask = builder.BuildAsync(cancellationTokenSource.Token); + await probe.BuildEntered.DefaultTimeout(); + + // BuildAsync registers the caller as waiting before it reaches its first await, so by the time this + // returns a task the surviving build is already accounted for by the cancellation below. + var survivingBuildTask = builder.BuildAsync(CancellationToken.None); + + cancellationTokenSource.Cancel(); + await Assert.ThrowsAnyAsync(() => canceledBuildTask.DefaultTimeout()); + + probe.ContinueBuilding(); + var application = await survivingBuildTask.WaitAsync(TimeSpan.FromSeconds(60)); + + // A background reclaim would dispose the factory as soon as the application arrived, which flips the + // builder into its disposed state. Owning the application means the builder still hands it back. + await Task.Delay(TimeSpan.FromSeconds(2)); + var rebuiltApplication = await builder.BuildAsync().DefaultTimeout(); + Assert.Same(application.Services, rebuiltApplication.Services); + Assert.False(probe.ApplicationDisposed.IsCompleted); + } + finally + { + probe.ContinueBuilding(); + await builder.DisposeAsync(); + probe.Dispose(); + } + } + [Theory] [InlineData(CreationSurface.Generic, "--operation", "publish")] [InlineData(CreationSurface.Generic, "--publisher", "manifest")] From b08bf913559dc38129b7ccc7fe0e192d35aa6c30 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 7 Aug 2026 17:25:59 -0400 Subject: [PATCH 07/21] Restore the browser token an AppHost cleared DistributedApplicationBuilder freezes the generated browser token into AppHost:BrowserToken while the builder is constructed, but DashboardOptions does not read that key until the application starts. AppHost code runs between those two points, so it could blank the key and DashboardEventHandlers would then launch the dashboard with Unsecured frontend authentication, silently downgrading the authenticated default EnableDashboard promises. Keep the generated token in DashboardTestingState and put it back once the AppHost entry point has finished configuring. The guard mirrors the string.IsNullOrEmpty check DashboardEventHandlers itself applies, so a deliberately chosen non-empty token is left alone, and it runs before the caller sees the builder, so the documented escape hatch to the anonymous dashboard is unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributedApplicationTestingBuilder.cs | 23 +++++++++++-- .../DashboardTestingBuilderTests.cs | 34 +++++++++++++------ .../TestingAppHost1.AppHost/Program.cs | 13 +++++-- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index d01eca7b869..f922ba570dc 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -303,9 +303,12 @@ private static void ConfigureDashboardTesting( return; } + var browserToken = TokenGenerator.GenerateToken(); + dashboardTestingState = new DashboardTestingState( Enabled: true, - DefaultWaitBehavior: testingOptions?.DefaultWaitBehavior ?? WaitBehavior.StopOnResourceUnavailable); + DefaultWaitBehavior: testingOptions?.DefaultWaitBehavior ?? WaitBehavior.StopOnResourceUnavailable, + BrowserToken: browserToken); applicationOptions.DisableDashboard = false; @@ -331,7 +334,7 @@ private static void ConfigureDashboardTesting( [ .. existingArgs, $"{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=false", - $"{KnownConfigNames.DashboardFrontendBrowserToken}={TokenGenerator.GenerateToken()}" + $"{KnownConfigNames.DashboardFrontendBrowserToken}={browserToken}" ]; applicationOptions.Args = hostBuilderOptions.Args; @@ -357,6 +360,20 @@ private static void ConfigureDashboardTesting(IDistributedApplicationBuilder bui // 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; + } + // Enabling the dashboard makes the hosting default wait indefinitely when a dependency becomes unavailable, // which would hang a test run instead of failing it. Re-pin the testing builder's fail-fast default unless // the caller asked for something else, since waiting is the useful behavior when the whole point is to open @@ -412,7 +429,7 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) /// 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. /// - private readonly record struct DashboardTestingState(bool Enabled, WaitBehavior DefaultWaitBehavior); + private readonly record struct DashboardTestingState(bool Enabled, WaitBehavior DefaultWaitBehavior, string? BrowserToken); private sealed class SuspendingDistributedApplicationFactory( Type entryPoint, diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs index 5488a999c92..f3fa652993b 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -456,21 +456,33 @@ public async Task DashboardEnabledThroughConfigureBuilderIsNotRejectedInPublishM Assert.True(builder.ExecutionContext.IsPublishMode); } + [Theory] + [InlineData(CreationSurface.Generic, "--clear-apphost-browser-token")] + [InlineData(CreationSurface.Generic, "--null-apphost-browser-token")] + [InlineData(CreationSurface.Type, "--clear-apphost-browser-token")] + [InlineData(CreationSurface.Type, "--null-apphost-browser-token")] + public async Task DashboardTestingRestoresTheBrowserTokenAnAppHostCleared(CreationSurface creationSurface, string appHostFlag) + { + // DistributedApplicationBuilder freezes the token into AppHost:BrowserToken during construction, but + // DashboardOptions does not read that key until the application starts. AppHost code runs between those + // points, so without the restore it can blank the key and DashboardEventHandlers launches the dashboard + // with Unsecured frontend authentication, defeating the authenticated default this opt-in promises. + await using var builder = await CreateDashboardBuilderAsync(creationSurface, [appHostFlag]); + await using var app = await builder.BuildAsync(); + + var dashboardOptions = app.Services.GetRequiredService>().Value; + Assert.False(string.IsNullOrEmpty(dashboardOptions.DashboardToken)); + } + [Theory] [InlineData(CreationSurface.Generic)] [InlineData(CreationSurface.Type)] - public async Task DashboardTestingDoesNotPinTheBrowserTokenAgainstTheAppHost(CreationSurface creationSurface) + public async Task DashboardTestingLeavesTheBrowserTokenToTheCallersBuilder(CreationSurface creationSurface) { - // Anonymous access is settled while the builder is constructed and cannot be taken back, but the browser - // token is only read out of AppHost:BrowserToken when DashboardOptions is first resolved, so an AppHost - // that clears the key still gets a dashboard with no credential. That asymmetry is deliberate rather than - // a hole: clearing this key is the only way to reach the anonymous dashboard path once EnableDashboard has - // appended its own arguments, the same escape hatch a test uses through the returned builder, and it does - // not fail silently because GetDashboardLoginUrlAsync then reports anonymous access instead of handing - // back an unauthenticated URL. Re-pinning the token here would take the escape hatch away from both. - await using var builder = await CreateDashboardBuilderAsync( - creationSurface, - ["--clear-apphost-browser-token"]); + // The restore above runs before the caller sees the builder, so a test that deliberately wants the + // anonymous dashboard keeps the documented escape hatch through the returned builder. + await using var builder = await CreateDashboardBuilderAsync(creationSurface, []); + builder.Configuration["AppHost:BrowserToken"] = ""; await using var app = await builder.BuildAsync(); var dashboardOptions = app.Services.GetRequiredService>().Value; diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index e65eaef2afe..9d8f361c80b 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -30,12 +30,19 @@ if (args.Contains("--clear-apphost-browser-token")) { - // Clearing the frozen token is the one dashboard setting an AppHost can still change after the testing - // builder has hardened things, because DashboardOptions does not read this key until the application starts. - // It is kept separate from --override-dashboard-testing-defaults so that flag keeps asserting on a token. + // The frozen token is the one dashboard setting an AppHost could still change after the testing builder had + // hardened things, because DashboardOptions does not read this key until the application starts. It is kept + // separate from --override-dashboard-testing-defaults so that flag keeps asserting on a token. builder.Configuration["AppHost:BrowserToken"] = ""; } +if (args.Contains("--null-apphost-browser-token")) +{ + // Null and empty are distinct configuration values, but DashboardEventHandlers collapses both to Unsecured + // frontend authentication through string.IsNullOrEmpty, so both need coverage. + builder.Configuration["AppHost:BrowserToken"] = null; +} + builder.Configuration["ConnectionStrings:cs"] = "testconnection"; builder.AddConnectionString("cs"); From b1a1fd4125f0c7e27b4b31727797cee9225ea344 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 9 Aug 2026 15:23:22 -0400 Subject: [PATCH 08/21] Close a disposal race that could leave the application running DisposeAsync read _appTcs.Task.IsCompletedSuccessfully and only called TrySetCanceled afterwards. OnBuiltCoreAsync disposes the application it built only when its own TrySetResult loses, so a TrySetResult landing between those two statements left neither side responsible: cancellation failed, DisposeAsync returned early, and OnBuiltCoreAsync saw a successful publish and ran OnBuilt. The host, its service provider, and the orchestrator processes it owns stayed alive for the rest of the test process. Claim the completion source first, then decide. TaskCompletionSource guarantees exactly one of TrySetCanceled and TrySetResult wins, so the two paths are now mutually exclusive and exhaustive: if cancellation wins there is nothing to tear down and OnBuiltCoreAsync disposes what it built; if it loses, the task is RanToCompletion and this method runs the full teardown. Also correct two doc claims that overstated what the code guarantees. Dashboard authentication is not fixed at construction - the generated browser token stays adjustable through the returned builder, which is what DashboardTestingLeavesTheBrowserTokenToTheCallersBuilder covers. And dynamic loopback ports prevent binding collisions, not cross-application access; the per-application browser token is what does that. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- .../DistributedApplicationFactory.cs | 10 +++++++++- .../DistributedApplicationTestingBuilderOptions.cs | 14 ++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs index 826766f4388..261f242ebcf 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs @@ -511,9 +511,17 @@ public virtual async ValueTask DisposeAsync() } OnDisposed(); + + // Claim the completion source before deciding whether there is an application to tear down. + // OnBuiltCoreAsync disposes the application it built only when its own TrySetResult loses, so exactly + // one of these two calls wins and the loser owns disposal. Reading IsCompletedSuccessfully first and + // cancelling afterwards left a window between the two: a TrySetResult landing inside it made this + // cancellation fail, this method return early, and OnBuiltCoreAsync see a successful publish - so + // neither side stopped the host, and it stayed running for the lifetime of the test process. + _appTcs.TrySetCanceled(); + if (_appTcs.Task is not { IsCompletedSuccessfully: true } appTask) { - _appTcs.TrySetCanceled(); return; } diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs index 9bc40895f95..2a7bba22d16 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs @@ -9,10 +9,10 @@ namespace Aspire.Hosting.Testing; /// Options that control how an is created. /// /// -/// These options are applied while the underlying distributed application builder is being constructed, because -/// dashboard services and dashboard authentication are selected during construction and cannot be added afterwards. -/// Settings that remain adjustable at runtime, such as port allocation, interactivity, and dependency waiting, can -/// still be overridden through the returned builder before the application is built. +/// These options are applied while the underlying distributed application builder is being constructed, because the +/// dashboard services themselves are selected during construction and cannot be added afterwards. The settings those +/// services read - port allocation, interactivity, dependency waiting, and the generated browser token - remain +/// adjustable through the returned builder until the application is built. /// /// /// The following example creates a testing builder that runs the dashboard: @@ -40,8 +40,10 @@ public sealed class DistributedApplicationTestingBuilderOptions /// The default is . /// /// - /// When the dashboard runs, it listens on authenticated loopback endpoints using dynamically assigned ports so - /// that concurrent test applications cannot collide or reach each other. Use + /// When the dashboard runs, it listens on loopback endpoints using dynamically assigned ports, so concurrent + /// test applications do not compete for a fixed port. Loopback still means every dashboard on the machine is + /// reachable from it; what keeps applications out of each other's dashboards is that each one generates its own + /// browser token. Use /// to obtain an /// authenticated URL for the running dashboard. /// From 0524c7014d77a9c912ec59488dfcd18329ad4a32 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 9 Aug 2026 15:55:22 -0400 Subject: [PATCH 09/21] Restore resource-service authentication after AppHost configuration The post-construction hardening restored only the browser token. The resource service sits in the same window: DistributedApplicationBuilder 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 between those points can downgrade either value. Either half alone is enough to unauthenticate the endpoint. ResourceServiceApiKeyAuthenticationHandler only checks the API key header while AuthMode is ApiKey and otherwise succeeds for every request, and ValidateResourceServiceOptions stops requiring a key once the mode is Unsecured, so clearing the mode does not even fail the start. That left the application's resource model readable on the loopback resource-service endpoint by anything that could reach it. Restore both halves together whenever the effective configuration would not be an authenticated ApiKey pairing, and seed a generated key pre-construction the same way the browser token is seeded, so an ambient ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY cannot hand every test application on a CI agent the same credential. Six new tests cover both downgrade halves across both creation surfaces and the per-application key freshness. All six fail when the guard and the seeding are removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- .../DistributedApplicationTestingBuilder.cs | 31 +++++++++++-- .../DashboardTestingBuilderTests.cs | 43 +++++++++++++++++++ .../TestingAppHost1.AppHost/Program.cs | 16 +++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index f922ba570dc..938e043431b 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -9,6 +9,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Dashboard; using Aspire.Hosting.Eventing; using Aspire.Hosting.Pipelines; using Microsoft.Extensions.Configuration; @@ -304,11 +305,13 @@ private static void ConfigureDashboardTesting( } var browserToken = TokenGenerator.GenerateToken(); + var resourceServiceApiKey = TokenGenerator.GenerateToken(); dashboardTestingState = new DashboardTestingState( Enabled: true, DefaultWaitBehavior: testingOptions?.DefaultWaitBehavior ?? WaitBehavior.StopOnResourceUnavailable, - BrowserToken: browserToken); + BrowserToken: browserToken, + ResourceServiceApiKey: resourceServiceApiKey); applicationOptions.DisableDashboard = false; @@ -323,6 +326,10 @@ private static void ConfigureDashboardTesting( // 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. @@ -334,7 +341,8 @@ private static void ConfigureDashboardTesting( [ .. existingArgs, $"{KnownConfigNames.DashboardUnsecuredAllowAnonymous}=false", - $"{KnownConfigNames.DashboardFrontendBrowserToken}={browserToken}" + $"{KnownConfigNames.DashboardFrontendBrowserToken}={browserToken}", + $"{KnownConfigNames.DashboardResourceServiceClientApiKey}={resourceServiceApiKey}" ]; applicationOptions.Args = hostBuilderOptions.Args; @@ -374,6 +382,23 @@ private static void ConfigureDashboardTesting(IDistributedApplicationBuilder bui 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"], nameof(ResourceServiceAuthMode.ApiKey), StringComparison.OrdinalIgnoreCase) + || string.IsNullOrEmpty(builder.Configuration["AppHost:ResourceService:ApiKey"])) + { + builder.Configuration["AppHost:ResourceService:AuthMode"] = nameof(ResourceServiceAuthMode.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. Re-pin the testing builder's fail-fast default unless // the caller asked for something else, since waiting is the useful behavior when the whole point is to open @@ -429,7 +454,7 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) /// 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. /// - private readonly record struct DashboardTestingState(bool Enabled, WaitBehavior DefaultWaitBehavior, string? BrowserToken); + private readonly record struct DashboardTestingState(bool Enabled, WaitBehavior DefaultWaitBehavior, string? BrowserToken, string? ResourceServiceApiKey); private sealed class SuspendingDistributedApplicationFactory( Type entryPoint, diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs index f3fa652993b..1049b15c805 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -25,6 +25,9 @@ public class DashboardTestingBuilderTests private const string ResourceServiceEndpointUrl = "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"; private const string DashboardFrontendBrowserToken = "ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN"; private const string AppHostBrowserToken = "AppHost:BrowserToken"; + private const string DashboardResourceServiceApiKey = "ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY"; + private const string AppHostResourceServiceAuthMode = "AppHost:ResourceService:AuthMode"; + private const string AppHostResourceServiceApiKey = "AppHost:ResourceService:ApiKey"; [Fact] public void DashboardIsDisabledByDefault() @@ -489,6 +492,46 @@ public async Task DashboardTestingLeavesTheBrowserTokenToTheCallersBuilder(Creat Assert.True(string.IsNullOrEmpty(dashboardOptions.DashboardToken)); } + [Theory] + [InlineData(CreationSurface.Generic, "--unsecure-apphost-resource-service")] + [InlineData(CreationSurface.Generic, "--clear-apphost-resource-service-key")] + [InlineData(CreationSurface.Type, "--unsecure-apphost-resource-service")] + [InlineData(CreationSurface.Type, "--clear-apphost-resource-service-key")] + public async Task DashboardTestingRestoresResourceServiceAuthenticationAnAppHostDowngraded(CreationSurface creationSurface, string appHostFlag) + { + // The resource-service credential sits in the same construction-to-start window as the browser token. + // DashboardServiceHost binds AppHost:ResourceService when the application starts, and + // ResourceServiceApiKeyAuthenticationHandler authenticates every request once the mode is not ApiKey, so an + // AppHost that downgrades either half would otherwise expose the resource model on the loopback endpoint. + await using var builder = await CreateDashboardBuilderAsync(creationSurface, [appHostFlag]); + + Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration[AppHostResourceServiceAuthMode]); + Assert.False(string.IsNullOrEmpty(builder.Configuration[AppHostResourceServiceApiKey])); + } + + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingGeneratesAFreshResourceServiceApiKeyPerApplication(CreationSurface creationSurface) + { + // An ambient ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY is what the builder adopts when configuration supplies + // one, which on a CI agent would hand every test application the same resource-service credential. + const string SharedKey = "shared-resource-service-key"; + string[] args = [$"--{DashboardResourceServiceApiKey}={SharedKey}"]; + + await using var first = await CreateDashboardBuilderAsync(creationSurface, args); + await using var second = await CreateDashboardBuilderAsync(creationSurface, args); + + var firstKey = first.Configuration[AppHostResourceServiceApiKey]; + var secondKey = second.Configuration[AppHostResourceServiceApiKey]; + + Assert.False(string.IsNullOrEmpty(firstKey)); + Assert.False(string.IsNullOrEmpty(secondKey)); + Assert.NotEqual(SharedKey, firstKey); + Assert.NotEqual(SharedKey, secondKey); + Assert.NotEqual(firstKey, secondKey); + } + [Fact] public async Task ConcurrentDisposeAsyncRunsTeardownOnce() { diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index 9d8f361c80b..d75a7a5abe8 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -43,6 +43,22 @@ builder.Configuration["AppHost:BrowserToken"] = null; } +if (args.Contains("--unsecure-apphost-resource-service")) +{ + // The resource service has the same post-construction window as the browser token: DashboardServiceHost does + // not bind this section until the application starts. Downgrading the mode alone is enough, because + // ResourceServiceApiKeyAuthenticationHandler only inspects the API key header while the mode is ApiKey. + builder.Configuration["AppHost:ResourceService:AuthMode"] = "Unsecured"; + builder.Configuration["AppHost:ResourceService:ApiKey"] = ""; +} + +if (args.Contains("--clear-apphost-resource-service-key")) +{ + // Clearing only the key is the other half. ValidateResourceServiceOptions would fail the start in this state, + // so without the restore the application does not come up at all rather than coming up unauthenticated. + builder.Configuration["AppHost:ResourceService:ApiKey"] = ""; +} + builder.Configuration["ConnectionStrings:cs"] = "testconnection"; builder.AddConnectionString("cs"); From d608e0a1525d1df4df573e7827382176a957db51 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sun, 9 Aug 2026 18:51:17 -0400 Subject: [PATCH 10/21] Keep the dashboard browser token out of AppHost logs in testing mode Enabling the dashboard for a test application generates a browser token, and the AppHost startup summary then wrote it to ILogger as ".../login?t={token}" -- both in the "Login to the dashboard at" line dotnet watch keys off and in the summary's Login URL row. Under a test host that logger is test and CI output, so a live credential for that application's dashboard was landing in a retained log artifact. That also contradicted two things this PR states: that no token is written to logs, and that the login URL is deliberately not emitted to test output. DashboardOptions gains SuppressLoginUrlInStartupSummary, bound from AppHost:SuppressDashboardLoginUrlInStartupSummary, which the testing builder seeds alongside its other dashboard defaults. DashboardEventHandlers then withholds the token from the summary, which drops the login URL and the watch line while leaving the dashboard and OTLP endpoint rows intact. Nothing changes outside testing mode: the key is unset, so the summary is byte-identical. Tests still reach the URL through GetDashboardLoginUrlAsync, which is the supported accessor and unaffected. Proven end to end rather than at the seam: the new test captures AppHost logs with FakeLogCollector, reads the token back out of the URL GetDashboardLoginUrlAsync returns, and asserts it appears nowhere -- while still requiring the summary itself to be present, so suppressing everything would not pass. Disabling the branch fails it and prints the leaked "- Login URL: .../login?t=a682d0a3..." row. 59/59. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 487b899d-d79d-4349-a84b-a026861bde7f --- .../DistributedApplicationTestingBuilder.cs | 10 ++++- .../Dashboard/DashboardEventHandlers.cs | 8 +++- .../Dashboard/DashboardOptions.cs | 13 +++++++ .../DashboardLoginUrlTests.cs | 37 +++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 938e043431b..4a2f6a9ea75 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -429,7 +429,15 @@ private static void AddDashboardTestingConfiguration(IConfigurationBuilder confi [KnownConfigNames.AllowUnsecuredTransport] = "true", [KnownConfigNames.DashboardUnsecuredAllowAnonymous] = "false", - [KnownConfigNames.InteractivityEnabled] = "false" + [KnownConfigNames.InteractivityEnabled] = "false", + + // The AppHost's startup summary otherwise writes ".../login?t={token}" through + // ILogger, which under a test host is test and CI output. The token is a + // live credential for this application's dashboard, and emitting it there is both the opposite of + // this opt-in's "no login URL in test output" behavior and a credential in a retained log artifact. + // Tests get the same URL from GetDashboardLoginUrlAsync. Only the login URL is withheld; the + // dashboard and OTLP endpoint lines are unaffected. + ["AppHost:SuppressDashboardLoginUrlInStartupSummary"] = "true" }); } diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 27c7395f501..57549d0ddae 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -542,7 +542,13 @@ private async Task DashboardStarted(ResourceReadyEvent @event, CancellationToken () => GetEndpointUrlAsync(dashboardResource, KnownEndpointNames.OtlpHttpEndpointName, cancellationToken), options.OtlpHttpEndpointUrl).ConfigureAwait(false); - LoggingHelpers.WriteDashboardSummary(distributedApplicationLogger, dashboardUrl, otlpGrpcUrl, otlpHttpUrl, browserToken, isContainer: false); + // Withholding the token drops the login URL from the summary and the separate "Login to the dashboard at" + // line, leaving the dashboard and OTLP endpoints. Testing sets this because its token is a live + // credential for a dashboard the test already has a supported accessor for, and the AppHost logger in + // that mode is test and CI output. + var summaryToken = options.SuppressLoginUrlInStartupSummary ? null : browserToken; + + LoggingHelpers.WriteDashboardSummary(distributedApplicationLogger, dashboardUrl, otlpGrpcUrl, otlpHttpUrl, summaryToken, isContainer: false); } private async ValueTask ResolveUrlAsync(Func> resolveCallback, string? configuredUrl) diff --git a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs index 5972cd59936..953260572bb 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs @@ -18,6 +18,17 @@ internal class DashboardOptions public string? ApiKey { get; set; } public string AspNetCoreEnvironment { get; set; } = "Production"; public bool? TelemetryOptOut { get; set; } + + /// + /// Suppresses the login URL, and only the login URL, from the AppHost's dashboard startup summary. + /// + /// + /// Set by Aspire.Hosting.Testing when it runs a dashboard for a test application. That dashboard's + /// browser token is generated per application and handed to the test through + /// GetDashboardLoginUrlAsync, so writing it to the AppHost logger only publishes a live credential + /// into test and CI output. The dashboard and OTLP endpoint lines are still written. + /// + public bool SuppressLoginUrlInStartupSummary { get; set; } } internal class ConfigureDefaultDashboardOptions(IConfiguration configuration, IOptions dcpOptions) : IConfigureOptions @@ -36,6 +47,8 @@ public void Configure(DashboardOptions options) options.AspNetCoreEnvironment = configuration[KnownAspNetCoreConfigNames.Environment] ?? "Production"; + options.SuppressLoginUrlInStartupSummary = bool.TryParse(configuration["AppHost:SuppressDashboardLoginUrlInStartupSummary"], out var suppressLoginUrl) && suppressLoginUrl; + options.TelemetryOptOut = bool.TryParse(configuration["ASPIRE_DASHBOARD_TELEMETRY_OPTOUT"], out var telemetryOptOut) ? telemetryOptOut : null; diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs index bbbcbd499a9..466b8b5e71f 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs @@ -296,6 +296,43 @@ public async Task GetDashboardLoginUrlAsyncPrefersCodespacesUrl() Assert.EndsWith(".app.github.dev", dashboardUri.Host, StringComparison.Ordinal); } + [Fact] + [RequiresFeature(TestFeature.Docker)] + public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLogs() + { + // The AppHost's startup summary writes ".../login?t={token}" through ILogger, + // and under a test host that logger is test and CI output. The token is a live credential for this + // application's dashboard, so it must not land in a retained log artifact. Tests that want the URL call + // GetDashboardLoginUrlAsync instead. + await using var builder = await CreateDashboardBuilderAsync(); + var logCollector = new FakeLogCollector(); + builder.Services.AddLogging(logging => logging.AddProvider(new FakeLoggerProvider(logCollector))); + + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + + // Read the token from the URL the supported accessor returns, so this asserts against the credential the + // dashboard actually validates rather than a copy of it. + var token = dashboardUri.Query["?t=".Length..]; + Assert.NotEmpty(token); + + var written = string.Join( + Environment.NewLine, + logCollector.GetSnapshot().Select(record => record.Message + " " + record.StructuredState?.Aggregate( + string.Empty, + (accumulated, pair) => accumulated + " " + pair.Value))); + + Assert.False( + written.Contains(token, StringComparison.Ordinal), + "The dashboard browser token was written to AppHost logs."); + + // The summary itself must still be produced; suppressing the whole thing would make this vacuous. + Assert.Contains("Aspire Dashboard", written, StringComparison.Ordinal); + } + private static Task CreateDashboardBuilderAsync(string[]? args = null) { return DistributedApplicationTestingBuilder.CreateAsync( From 3bca43422f0877f28dd8f595fe85614135d46027 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 10 Aug 2026 11:49:16 -0400 Subject: [PATCH 11/21] Keep dashboard testing focused Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0d1cf760-b1bc-46ba-a6d4-628354b00f2c --- .../Aspire.Hosting.Testing.csproj | 8 + .../DistributedApplicationFactory.cs | 41 +- ...utedApplicationHostingTestingExtensions.cs | 30 +- .../DistributedApplicationTestingBuilder.cs | 184 +------- ...ributedApplicationTestingBuilderOptions.cs | 21 +- .../Properties/Resources.Designer.cs | 12 +- src/Aspire.Hosting/Aspire.Hosting.csproj | 6 - .../Backchannel/DashboardUrlsHelper.cs | 43 +- .../DashboardLoginUrlTests.cs | 177 +------ .../DashboardTestingBuilderTests.cs | 436 +----------------- .../TestingAppHost1.AppHost/Program.cs | 25 - .../TestingAppHostBuildProbe.cs | 147 ------ 12 files changed, 81 insertions(+), 1049 deletions(-) delete mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs diff --git a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj index ac1023a4328..49a983d2f81 100644 --- a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj +++ b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj @@ -11,6 +11,14 @@ false + + + + + + + + diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs index 261f242ebcf..7af9dd22dd5 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationFactory.cs @@ -26,11 +26,6 @@ public class DistributedApplicationFactory(Type entryPoint, string[] args) : IDi private readonly TaskCompletionSource _builderTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _appTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly CancellationTokenSource _disposingCts = new(); - - // Disposal must be claimed atomically. Reading _disposingCts.IsCancellationRequested is not enough because - // OnDisposed() cancels it only after the guard, so two concurrent disposers could both run the teardown - // below and race on StopAsync()/DisposeAsync() of the same application. - private int _disposeClaimed; private TimeSpan _shutdownTimeout = TimeSpan.FromSeconds(10); private readonly object _lockObj = new(); private bool _entryPointStarted; @@ -171,28 +166,10 @@ private static string[] ThrowIfNullOrContainsIsNullOrEmpty(string[] args) return args; } - private async Task OnBuiltCoreAsync(DistributedApplication application) + private void OnBuiltCore(DistributedApplication application) { _shutdownTimeout = application.Services.GetService>()?.Value.ShutdownTimeout ?? _shutdownTimeout; - - if (!_appTcs.TrySetResult(application)) - { - // The factory was disposed, or the entry point already faulted, while this application was still - // being built. No caller will ever receive it, so nothing else holds a reference that would tear - // down the host, its service provider, or the orchestrator processes it owns. Reclaim it here - // instead of leaving it running for the lifetime of the test process. - try - { - await application.DisposeAsync().ConfigureAwait(false); - } - catch - { - // No owner remains to observe a disposal failure for an application nobody asked for. - } - - return; - } - + _appTcs.TrySetResult(application); OnBuilt(application); } @@ -432,7 +409,7 @@ private async Task InvokeEntryPoint(Func() ?? throw new InvalidOperationException($"Application did not register an implementation of {typeof(IHostApplicationLifetime)}."); - await OnBuiltCoreAsync(app).ConfigureAwait(false); + OnBuiltCore(app); } catch (Exception exception) { @@ -504,24 +481,16 @@ private void OnDisposed() /// public virtual async ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref _disposeClaimed, 1) == 1) + if (_disposingCts.IsCancellationRequested) { // Dispose already called. return; } OnDisposed(); - - // Claim the completion source before deciding whether there is an application to tear down. - // OnBuiltCoreAsync disposes the application it built only when its own TrySetResult loses, so exactly - // one of these two calls wins and the loser owns disposal. Reading IsCompletedSuccessfully first and - // cancelling afterwards left a window between the two: a TrySetResult landing inside it made this - // cancellation fail, this method return early, and OnBuiltCoreAsync see a successful publish - so - // neither side stopped the host, and it stayed running for the lifetime of the test process. - _appTcs.TrySetCanceled(); - if (_appTcs.Task is not { IsCompletedSuccessfully: true } appTask) { + _appTcs.TrySetCanceled(); return; } diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs index 9d02b48d1b1..93e8e4d480b 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs @@ -2,10 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Backchannel; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; namespace Aspire.Hosting.Testing; @@ -14,6 +13,8 @@ namespace Aspire.Hosting.Testing; /// public static class DistributedApplicationHostingTestingExtensions { + private const string DashboardResourceName = "aspire-dashboard"; + /// /// Gets the authenticated login URL for the running Aspire dashboard. /// @@ -80,24 +81,33 @@ public static async Task GetDashboardLoginUrlAsync( ThrowIfNotStarted(app, Properties.Resources.DashboardLoginUrlApplicationNotStartedExceptionMessage); cancellationToken.ThrowIfCancellationRequested(); - var logger = app.Services.GetRequiredService().CreateLogger( - "Aspire.Hosting.Testing.DistributedApplicationDashboard"); - var dashboardInfo = await DashboardUrlsHelper.GetDashboardConnectionInfoOrThrowAsync( - app.Services, - logger, + await app.ResourceNotifications.WaitForResourceHealthyAsync( + DashboardResourceName, + WaitBehavior.StopOnResourceUnavailable, cancellationToken).ConfigureAwait(false); - var dashboardUrl = dashboardInfo.CodespacesUrlWithLoginToken ?? dashboardInfo.BaseUrlWithLoginToken; - if (!dashboardInfo.IsHealthy || !Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri)) + var applicationModel = app.Services.GetRequiredService(); + if (!applicationModel.Resources.TryGetByName(DashboardResourceName, out var resource) || + resource is not IResourceWithEndpoints dashboardResource) { throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlUnavailableExceptionMessage); } - if (!dashboardInfo.HasBrowserToken) + var httpsEndpoint = dashboardResource.GetEndpoint("https"); + var httpEndpoint = dashboardResource.GetEndpoint("http"); + var dashboardEndpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint; + var browserToken = app.Services.GetRequiredService()["AppHost:BrowserToken"]; + if (string.IsNullOrEmpty(browserToken)) { throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlAnonymousExceptionMessage); } + if (!dashboardEndpoint.Exists || + !Uri.TryCreate($"{dashboardEndpoint.Url.TrimEnd('/')}/login?t={browserToken}", UriKind.Absolute, out var dashboardUri)) + { + throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlUnavailableExceptionMessage); + } + return dashboardUri; } diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index 4a2f6a9ea75..a97d51de2b6 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -9,7 +9,6 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using Aspire.Hosting.ApplicationModel; -using Aspire.Hosting.Dashboard; using Aspire.Hosting.Eventing; using Aspire.Hosting.Pipelines; using Microsoft.Extensions.Configuration; @@ -192,15 +191,7 @@ private static async Task CreateAsyncCore 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; - } + return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); } /// @@ -309,7 +300,6 @@ private static void ConfigureDashboardTesting( dashboardTestingState = new DashboardTestingState( Enabled: true, - DefaultWaitBehavior: testingOptions?.DefaultWaitBehavior ?? WaitBehavior.StopOnResourceUnavailable, BrowserToken: browserToken, ResourceServiceApiKey: resourceServiceApiKey); @@ -392,20 +382,17 @@ private static void ConfigureDashboardTesting(IDistributedApplicationBuilder bui // 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"], nameof(ResourceServiceAuthMode.ApiKey), StringComparison.OrdinalIgnoreCase) + if (!string.Equals(builder.Configuration["AppHost:ResourceService:AuthMode"], "ApiKey", StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(builder.Configuration["AppHost:ResourceService:ApiKey"])) { - builder.Configuration["AppHost:ResourceService:AuthMode"] = nameof(ResourceServiceAuthMode.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. Re-pin the testing builder's fail-fast default unless - // the caller asked for something else, since waiting is the useful behavior when the whole point is to open - // the dashboard and look at the stuck resource. A later user registration still overrides this. - var defaultWaitBehavior = dashboardTestingState.DefaultWaitBehavior; + // which would hang a test run instead of failing it. A later user registration can still override this. builder.Services.Configure( - options => options.DefaultWaitBehavior = defaultWaitBehavior); + options => options.DefaultWaitBehavior = WaitBehavior.StopOnResourceUnavailable); } private static void AddDashboardTestingConfiguration(IConfigurationBuilder configuration) @@ -462,7 +449,7 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] args) /// 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. /// - private readonly record struct DashboardTestingState(bool Enabled, WaitBehavior DefaultWaitBehavior, string? BrowserToken, string? ResourceServiceApiKey); + private readonly record struct DashboardTestingState(bool Enabled, string? BrowserToken, string? ResourceServiceApiKey); private sealed class SuspendingDistributedApplicationFactory( Type entryPoint, @@ -473,24 +460,6 @@ private sealed class SuspendingDistributedApplicationFactory( { private readonly SemaphoreSlim _continueBuilding = new(0); - // Guards the pair of (continuation state, outstanding build count). They have to move together: a caller - // may only start waiting for the application while no other caller has abandoned it, and an abandoning - // caller may only claim it once no other caller is still waiting. Interlocked operations on the two fields - // cannot express that, and nothing is awaited while the lock is held. - private readonly object _buildStateLock = new(); - - // Tracks whether the suspended AppHost entry point has been released, and why. Disposal must win over a - // concurrent BuildAsync so a rejected builder cannot continue into Build() after the factory is gone. - private const int ContinuationSuspended = 0; - private const int ContinuationReleased = 1; - private const int ContinuationAbandoned = 2; - private const int ContinuationDisposed = 3; - - private int _buildingContinuationState; - - // Number of BuildAsync calls that have released the AppHost and are still waiting for the application. - private int _outstandingBuilds; - // 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; @@ -515,159 +484,26 @@ protected override void OnBuilding(DistributedApplicationBuilder applicationBuil // Wait until the owner signals that building can continue by calling BuildAsync(). _continueBuilding.Wait(); - if (Volatile.Read(ref _buildingContinuationState) == ContinuationDisposed) - { - throw CreateDisposedException(); - } } public async Task BuildAsync(CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - - bool releaseAppHost; - lock (_buildStateLock) - { - switch (_buildingContinuationState) - { - case ContinuationDisposed: - throw CreateDisposedException(); - case ContinuationAbandoned: - throw CreateAbandonedException(); - } - - releaseAppHost = _buildingContinuationState == ContinuationSuspended; - Volatile.Write(ref _buildingContinuationState, ContinuationReleased); - _outstandingBuilds++; - } - - if (releaseAppHost) - { - _continueBuilding.Release(); - } - - var canceled = false; - try - { - return await ResolveApplicationAsync(cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - canceled = true; - throw; - } - catch (OperationCanceledException) when (Volatile.Read(ref _buildingContinuationState) == ContinuationDisposed) - { - throw CreateDisposedException(); - } - finally - { - if (TryAbandonApplication(canceled)) - { - // Once the AppHost has been released, it can still finish building after the caller stops waiting. - // Cancellation must return to the caller promptly, so reclaim that application on a background - // continuation rather than blocking here until an AppHost that may never finish completes. - ReclaimApplicationInBackground(); - } - } - } - - /// - /// Records that a call stopped waiting for the application, and reports whether it - /// abandoned it, meaning no caller is left to take ownership of an application that still arrives. - /// - private bool TryAbandonApplication(bool canceled) - { - lock (_buildStateLock) - { - _outstandingBuilds--; - - // A caller that is still waiting takes ownership of the application and disposes it through the - // builder, so only the last caller to cancel may reclaim it. Disposal already owns the teardown. - if (!canceled || _outstandingBuilds > 0 || _buildingContinuationState != ContinuationReleased) - { - return false; - } - - // The AppHost entry point produces exactly one application, and it is now spoken for by the - // background reclaim, so later builds have to be rejected rather than handed a disposed instance. - Volatile.Write(ref _buildingContinuationState, ContinuationAbandoned); - return true; - } - } - - /// - /// Disposes the factory once a released AppHost finishes building, so an application the caller abandoned - /// after cancellation cannot keep running. - /// - /// - /// This is best effort. If the caller disposes the builder first, this wait is aborted by that disposal and - /// reclaims the late-arriving application instead. - /// - private void ReclaimApplicationInBackground() - { - _ = Task.Run(async () => - { - try - { - _ = await ResolveApplicationAsync(CancellationToken.None).ConfigureAwait(false); - await DisposeAsync().ConfigureAwait(false); - } - catch - { - // The AppHost can fail, or this wait can be aborted by the caller's own disposal, after the - // caller stopped waiting. No caller remains to observe that, and the factory reclaims any - // application that arrives after disposal, so swallow it rather than raising an unobserved - // task exception on the finalizer thread. - } - }); - } - - private static ObjectDisposedException CreateDisposedException() - { - // Report the public interface rather than this internal factory type so callers see the object they own. - return new ObjectDisposedException( - nameof(IDistributedApplicationTestingBuilder), - "The testing builder was disposed before the application was built."); - } - - private static InvalidOperationException CreateAbandonedException() - { - return new InvalidOperationException( - "The application was abandoned because the BuildAsync call that released the AppHost was canceled, " + - "and it is being disposed. An AppHost entry point builds a single application, so building again " + - "requires a new testing builder."); + _continueBuilding.Release(); + return await ResolveApplicationAsync(cancellationToken).ConfigureAwait(false); } public override async ValueTask DisposeAsync() { - PrepareForDisposal(); + _continueBuilding.Release(); await base.DisposeAsync().ConfigureAwait(false); } public override void Dispose() { - PrepareForDisposal(); + _continueBuilding.Release(); base.Dispose(); } - private void PrepareForDisposal() - { - bool releaseAppHost; - lock (_buildStateLock) - { - releaseAppHost = _buildingContinuationState == ContinuationSuspended; - Volatile.Write(ref _buildingContinuationState, ContinuationDisposed); - } - - if (releaseAppHost) - { - // Abort on the AppHost entry-point thread instead of allowing a rejected or canceled - // builder to continue into Build() after the factory has already been disposed. - _continueBuilding.Release(); - } - } - private sealed class Builder(SuspendingDistributedApplicationFactory factory, DistributedApplicationBuilder innerBuilder) : IDistributedApplicationTestingBuilder { public ConfigurationManager Configuration => innerBuilder.Configuration; diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs index 2a7bba22d16..19172091b67 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.ApplicationModel; - namespace Aspire.Hosting.Testing; /// @@ -11,7 +9,7 @@ namespace Aspire.Hosting.Testing; /// /// These options are applied while the underlying distributed application builder is being constructed, because the /// dashboard services themselves are selected during construction and cannot be added afterwards. The settings those -/// services read - port allocation, interactivity, dependency waiting, and the generated browser token - remain +/// services read - port allocation, interactivity, and the generated browser token - remain /// adjustable through the returned builder until the application is built. /// /// @@ -48,21 +46,4 @@ public sealed class DistributedApplicationTestingBuilderOptions /// authenticated URL for the running dashboard. /// public bool EnableDashboard { get; set; } - - /// - /// Gets or sets the default applied when a resource waits on a dependency that has - /// become unavailable. - /// - /// - /// The wait behavior to apply, or to use the default. - /// The default is , which fails the test run instead of - /// hanging it. - /// - /// - /// Set this to when debugging with - /// , so that a failing dependency keeps the application alive long enough to be - /// inspected in the dashboard instead of tearing it down. This option has no effect unless the dashboard runs, - /// because the testing builder already fails fast otherwise. - /// - public WaitBehavior? DefaultWaitBehavior { get; set; } } diff --git a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs index 0564c023cb2..6e46dee8948 100644 --- a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs +++ b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs @@ -77,7 +77,7 @@ internal static string DashboardDisabledExceptionMessage { return ResourceManager.GetString("DashboardDisabledExceptionMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to The dashboard login URL is not available because anonymous dashboard access is enabled.. /// @@ -86,7 +86,7 @@ internal static string DashboardLoginUrlAnonymousExceptionMessage { return ResourceManager.GetString("DashboardLoginUrlAnonymousExceptionMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to The application must be started before retrieving the dashboard login URL.. /// @@ -95,7 +95,7 @@ internal static string DashboardLoginUrlApplicationNotStartedExceptionMessage { return ResourceManager.GetString("DashboardLoginUrlApplicationNotStartedExceptionMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to The dashboard login URL is not available in publish mode.. /// @@ -104,7 +104,7 @@ internal static string DashboardLoginUrlPublishModeExceptionMessage { return ResourceManager.GetString("DashboardLoginUrlPublishModeExceptionMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to The dashboard login URL is not available.. /// @@ -113,7 +113,7 @@ internal static string DashboardLoginUrlUnavailableExceptionMessage { return ResourceManager.GetString("DashboardLoginUrlUnavailableExceptionMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Dashboard testing is not supported in publish mode.. /// @@ -122,7 +122,7 @@ internal static string DashboardTestingPublishModeExceptionMessage { return ResourceManager.GetString("DashboardTestingPublishModeExceptionMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Endpoint '{0}' for resource '{1}' not found.. /// diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index 036918518f3..6f674b02705 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -137,12 +137,6 @@ - - diff --git a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs index 74d36cb2ee0..9522d2d1fa1 100644 --- a/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs +++ b/src/Aspire.Hosting/Backchannel/DashboardUrlsHelper.cs @@ -17,6 +17,7 @@ namespace Aspire.Hosting.Backchannel; /// internal static class DashboardUrlsHelper { + /// /// Gets all dashboard connection information in a single call. /// Waits for the dashboard to become healthy before returning. @@ -29,38 +30,6 @@ public static async Task GetDashboardConnectionInfoAsyn IServiceProvider serviceProvider, ILogger logger, CancellationToken cancellationToken = default) - { - return await GetDashboardConnectionInfoCoreAsync( - serviceProvider, - logger, - throwOnDashboardFailure: false, - cancellationToken).ConfigureAwait(false); - } - - /// - /// Gets all dashboard connection information while preserving terminal dashboard failures. - /// - /// The service provider. - /// The logger for diagnostic output. - /// A cancellation token. - /// Complete dashboard connection information. - internal static async Task GetDashboardConnectionInfoOrThrowAsync( - IServiceProvider serviceProvider, - ILogger logger, - CancellationToken cancellationToken) - { - return await GetDashboardConnectionInfoCoreAsync( - serviceProvider, - logger, - throwOnDashboardFailure: true, - cancellationToken).ConfigureAwait(false); - } - - private static async Task GetDashboardConnectionInfoCoreAsync( - IServiceProvider serviceProvider, - ILogger logger, - bool throwOnDashboardFailure, - CancellationToken cancellationToken) { // Check whether the dashboard resource is registered at all. If it isn't (e.g. the user // called DisableDashboard()), return immediately rather than waiting forever for a resource @@ -98,12 +67,6 @@ await resourceNotificationService.WaitForResourceHealthyAsync( waitActivity.SetError(ex); activity.SetDashboardHealthy(false); logger.LogWarning(ex, "An error occurred while waiting for the Aspire Dashboard to become healthy."); - - if (throwOnDashboardFailure) - { - throw; - } - return DashboardConnectionInfo.Unhealthy; } } @@ -163,7 +126,7 @@ await resourceNotificationService.WaitForResourceHealthyAsync( if (!string.IsNullOrEmpty(apiBaseUrl)) { baseUrlWithLoginToken = !string.IsNullOrEmpty(dashboardOptions.DashboardToken) - ? $"{apiBaseUrl.TrimEnd('/')}/login?t={Uri.EscapeDataString(dashboardOptions.DashboardToken)}" + ? $"{apiBaseUrl.TrimEnd('/')}/login?t={dashboardOptions.DashboardToken}" : apiBaseUrl; var rewrittenUrl = codespacesUrlRewriter?.RewriteUrl(baseUrlWithLoginToken); @@ -181,7 +144,6 @@ await resourceNotificationService.WaitForResourceHealthyAsync( IsHealthy = true, ApiBaseUrl = apiBaseUrl, ApiToken = dashboardOptions.ApiKey, - HasBrowserToken = !string.IsNullOrEmpty(dashboardOptions.DashboardToken), BaseUrlWithLoginToken = baseUrlWithLoginToken, CodespacesUrlWithLoginToken = codespacesUrlWithLoginToken }; @@ -226,7 +188,6 @@ internal sealed class DashboardConnectionInfo public bool IsHealthy { get; init; } public string? ApiBaseUrl { get; init; } public string? ApiToken { get; init; } - public bool HasBrowserToken { get; init; } /// /// Gets the resolved dashboard URL. /// When browser token authentication is enabled, this value includes the login token. diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs index 466b8b5e71f..88f29cee7b0 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs @@ -2,13 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; -using Aspire.Hosting.Backchannel; -using Aspire.Hosting.Diagnostics; using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using Xunit; @@ -60,28 +56,6 @@ public async Task GetDashboardLoginUrlAsyncAuthenticatesDashboardBrowser() Assert.Equal(HttpStatusCode.OK, protectedResponse.StatusCode); } - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task ConcurrentApplicationsUseDifferentDashboardLoginUrls() - { - await using var firstBuilder = await CreateDashboardBuilderAsync(); - await using var secondBuilder = await CreateDashboardBuilderAsync(); - await using var firstApp = await firstBuilder.BuildAsync(); - await using var secondApp = await secondBuilder.BuildAsync(); - - await Task.WhenAll(firstApp.StartAsync(), secondApp.StartAsync()).WaitAsync(TestConstants.LongTimeoutTimeSpan); - - using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var urls = await Task.WhenAll( - firstApp.GetDashboardLoginUrlAsync(cancellationTokenSource.Token), - secondApp.GetDashboardLoginUrlAsync(cancellationTokenSource.Token)); - - Assert.Equal(Uri.UriSchemeHttp, urls[0].Scheme); - Assert.Equal(Uri.UriSchemeHttp, urls[1].Scheme); - Assert.NotEqual(urls[0].Port, urls[1].Port); - Assert.NotEqual(urls[0].Query, urls[1].Query); - } - [Fact] [RequiresFeature(TestFeature.Docker)] public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() @@ -104,6 +78,9 @@ public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() new($"{firstBaseUrl}/login?t=%25") ]; + Assert.NotEqual(firstUrl.Port, secondUrl.Port); + Assert.NotEqual(firstUrl.Query, secondUrl.Query); + foreach (var invalidLoginUrl in invalidLoginUrls) { using var handler = new HttpClientHandler @@ -164,48 +141,6 @@ public async Task GetDashboardLoginUrlAsyncThrowsBeforeApplicationStarts() Assert.Equal(TestingResources.DashboardLoginUrlApplicationNotStartedExceptionMessage, exception.Message); } - [Fact] - public async Task GetDashboardLoginUrlAsyncHonorsCancellationAfterDashboardWaitStarts() - { - var (app, notificationLogger) = await CreateApplicationWithBlockedDashboardAsync(); - await using var _ = app; - using var cancellationTokenSource = new CancellationTokenSource(); - var loginUrlTask = app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); - - Assert.Contains( - notificationLogger.Collector.GetSnapshot(), - entry => entry.Message.Contains( - $"Waiting for resource '{KnownResourceNames.AspireDashboard}'", - StringComparison.Ordinal)); - Assert.False(loginUrlTask.IsCompleted); - - cancellationTokenSource.Cancel(); - - await Assert.ThrowsAnyAsync(() => loginUrlTask); - } - - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task GetDashboardLoginUrlAsyncThrowsAfterApplicationIsDisposed() - { - await using var builder = await CreateDashboardBuilderAsync(); - var app = await builder.BuildAsync(); - - try - { - await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - _ = await app.GetDashboardLoginUrlAsync(default); - await app.DisposeAsync(); - - await Assert.ThrowsAsync( - () => app.GetDashboardLoginUrlAsync(default)); - } - finally - { - await app.DisposeAsync(); - } - } - [Fact] public async Task GetDashboardLoginUrlAsyncThrowsInPublishMode() { @@ -239,71 +174,10 @@ public async Task GetDashboardLoginUrlAsyncPreservesTerminalDashboardFailure() Assert.Contains(KnownResourceNames.AspireDashboard, exception.Message, StringComparison.Ordinal); } - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task CanonicalDashboardLoginUrlEscapesBrowserToken() - { - const string browserToken = "token+with&reserved=value"; - await using var builder = await CreateDashboardBuilderAsync(); - builder.Configuration["AppHost:BrowserToken"] = browserToken; - await using var app = await builder.BuildAsync(); - await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - - using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var logger = app.Services.GetRequiredService().CreateLogger("DashboardLoginUrlTests"); - var info = await DashboardUrlsHelper.GetDashboardConnectionInfoAsync( - app.Services, - logger, - cancellationTokenSource.Token); - Assert.NotNull(info.BaseUrlWithLoginToken); - - Assert.EndsWith( - $"/login?t={Uri.EscapeDataString(browserToken)}", - info.BaseUrlWithLoginToken, - StringComparison.Ordinal); - } - - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task GetDashboardLoginUrlAsyncUsesConfiguredTargetHost() - { - await using var builder = await CreateDashboardBuilderAsync(); - builder.Configuration["ASPNETCORE_URLS"] = "http://aspire-dashboard.dev.localhost:0"; - await using var app = await builder.BuildAsync(); - await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - - using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var dashboardUri = await app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); - - Assert.Equal("aspire-dashboard.dev.localhost", dashboardUri.Host); - } - - [Fact] - [RequiresFeature(TestFeature.Docker)] - public async Task GetDashboardLoginUrlAsyncPrefersCodespacesUrl() - { - await using var builder = await CreateDashboardBuilderAsync(); - builder.Configuration["CODESPACES"] = "true"; - builder.Configuration["CODESPACE_NAME"] = "test-codespace"; - builder.Configuration["GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN"] = "app.github.dev"; - await using var app = await builder.BuildAsync(); - await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - - using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var dashboardUri = await app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); - - Assert.StartsWith("test-codespace-", dashboardUri.Host, StringComparison.Ordinal); - Assert.EndsWith(".app.github.dev", dashboardUri.Host, StringComparison.Ordinal); - } - [Fact] [RequiresFeature(TestFeature.Docker)] public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLogs() { - // The AppHost's startup summary writes ".../login?t={token}" through ILogger, - // and under a test host that logger is test and CI output. The token is a live credential for this - // application's dashboard, so it must not land in a retained log artifact. Tests that want the URL call - // GetDashboardLoginUrlAsync instead. await using var builder = await CreateDashboardBuilderAsync(); var logCollector = new FakeLogCollector(); builder.Services.AddLogging(logging => logging.AddProvider(new FakeLoggerProvider(logCollector))); @@ -313,9 +187,6 @@ public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLog using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); var dashboardUri = await app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); - - // Read the token from the URL the supported accessor returns, so this asserts against the credential the - // dashboard actually validates rather than a copy of it. var token = dashboardUri.Query["?t=".Length..]; Assert.NotEmpty(token); @@ -328,16 +199,14 @@ public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLog Assert.False( written.Contains(token, StringComparison.Ordinal), "The dashboard browser token was written to AppHost logs."); - - // The summary itself must still be produced; suppressing the whole thing would make this vacuous. Assert.Contains("Aspire Dashboard", written, StringComparison.Ordinal); } - private static Task CreateDashboardBuilderAsync(string[]? args = null) + private static Task CreateDashboardBuilderAsync() { return DistributedApplicationTestingBuilder.CreateAsync( CreateDashboardOptions(), - args ?? []); + []); } private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() @@ -347,40 +216,4 @@ private static DistributedApplicationTestingBuilderOptions CreateDashboardOption EnableDashboard = true }; } - - private static async Task<(DistributedApplication App, FakeLogger NotificationLogger)> - CreateApplicationWithBlockedDashboardAsync() - { - var dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, "dashboard", "."); - var notificationLogger = new FakeLogger(); - var host = new HostBuilder() - .ConfigureServices(services => - { - services.AddLogging(); - services.AddSingleton(new DistributedApplicationModel([dashboardResource])); - services.AddSingleton(new DistributedApplicationOptions - { - DisableDashboard = false - }); - services.AddSingleton(serviceProvider => - new DistributedApplicationExecutionContext( - new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) - { - Services = serviceProvider - })); - services.AddSingleton(); - services.AddSingleton(serviceProvider => - new ResourceNotificationService( - notificationLogger, - serviceProvider.GetRequiredService(), - serviceProvider, - serviceProvider.GetRequiredService())); - services.AddSingleton( - new ProfilingTelemetry(new ConfigurationBuilder().Build())); - }) - .Build(); - await host.StartAsync(); - - return (new DistributedApplication(host), notificationLogger); - } } diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs index 1049b15c805..cfada7b341f 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -3,12 +3,8 @@ #pragma warning disable ASPIREINTERACTION001 -using System.Runtime.CompilerServices; using Aspire.Hosting.Dashboard; -using Aspire.Hosting.Dcp; -using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Xunit; using TestingResources = Aspire.Hosting.Testing.Properties.Resources; @@ -46,7 +42,7 @@ public void DashboardIsDisabledByDefault() [InlineData(CreationSurface.AdHoc)] public async Task DashboardCanBeEnabledAtBuilderCreation(CreationSurface creationSurface) { - var builder = await CreateDashboardBuilderAsync(creationSurface, []); + await using var builder = await CreateDashboardBuilderAsync(creationSurface, []); Assert.Single(builder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); AssertDashboardTestingDefaults(builder); @@ -58,7 +54,7 @@ public async Task DashboardCanBeEnabledAtBuilderCreation(CreationSurface creatio [InlineData(CreationSurface.Generic)] [InlineData(CreationSurface.Type)] [InlineData(CreationSurface.AdHoc)] - public async Task DashboardTestingDefaultsOverrideConfigurationPresentDuringCreation(CreationSurface creationSurface) + public async Task DashboardTestingDefaultsOverrideCreationConfiguration(CreationSurface creationSurface) { string[] args = [ @@ -74,19 +70,7 @@ public async Task DashboardTestingDefaultsOverrideConfigurationPresentDuringCrea await using var builder = await CreateDashboardBuilderAsync(creationSurface, args); AssertDashboardTestingDefaults(builder); - Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration["AppHost:ResourceService:AuthMode"]); - } - - [Theory] - [InlineData(CreationSurface.Generic)] - [InlineData(CreationSurface.Type)] - public async Task DashboardTestingDefaultsOverrideAppHostConfiguration(CreationSurface creationSurface) - { - await using var builder = await CreateDashboardBuilderAsync( - creationSurface, - ["--override-dashboard-testing-defaults"]); - - AssertDashboardTestingDefaults(builder); + Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration[AppHostResourceServiceAuthMode]); } [Theory] @@ -95,8 +79,6 @@ public async Task DashboardTestingDefaultsOverrideAppHostConfiguration(CreationS [InlineData(CreationSurface.AdHoc)] public async Task DashboardTestingGeneratesAFreshBrowserTokenPerApplication(CreationSurface creationSurface) { - // A token shared by every application under test is barely better than anonymous access, and this is - // exactly the shape an ambient ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN on a CI agent would take. const string SharedToken = "shared-browser-token"; string[] args = [$"--{DashboardFrontendBrowserToken}={SharedToken}"]; @@ -117,7 +99,6 @@ public async Task DashboardTestingGeneratesAFreshBrowserTokenPerApplication(Crea public async Task DashboardTestingDefaultsAreNonInteractiveAndFailFast() { var builder = DistributedApplicationTestingBuilder.Create(CreateDashboardOptions(), []); - await using var app = await builder.BuildAsync(); Assert.False(app.Services.GetRequiredService().IsAvailable); @@ -126,45 +107,6 @@ public async Task DashboardTestingDefaultsAreNonInteractiveAndFailFast() app.Services.GetRequiredService>().Value.DefaultWaitBehavior); } - [Fact] - public async Task DashboardTestingDefaultWaitBehaviorCanBeOverriddenThroughOptions() - { - // Fail-fast is right for an unattended run, but when the dashboard is up to be looked at, waiting keeps the - // stuck resource alive long enough to inspect instead of tearing the application down. - var options = CreateDashboardOptions(); - options.DefaultWaitBehavior = WaitBehavior.WaitOnResourceUnavailable; - - var builder = DistributedApplicationTestingBuilder.Create(options, []); - - await using var app = await builder.BuildAsync(); - - Assert.Equal( - WaitBehavior.WaitOnResourceUnavailable, - app.Services.GetRequiredService>().Value.DefaultWaitBehavior); - } - - [Fact] - public async Task DashboardTestingDefaultsCanBeOverriddenAfterCreation() - { - var builder = DistributedApplicationTestingBuilder.Create(CreateDashboardOptions(), []); - builder.Configuration[InteractivityEnabled] = "true"; - builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; - builder.Configuration[AspNetCoreUrls] = "http://127.0.0.1:12345"; - builder.Services.Configure( - options => options.DefaultWaitBehavior = WaitBehavior.WaitOnResourceUnavailable); - - await using var app = await builder.BuildAsync(); - - Assert.True(app.Services.GetRequiredService().IsAvailable); - Assert.False(app.Services.GetRequiredService>().Value.RandomizePorts); - Assert.Equal( - "http://127.0.0.1:12345", - app.Services.GetRequiredService>().Value.DashboardUrl); - Assert.Equal( - WaitBehavior.WaitOnResourceUnavailable, - app.Services.GetRequiredService>().Value.DefaultWaitBehavior); - } - [Fact] public async Task DashboardTestingOptionsCannotBeNull() { @@ -178,9 +120,6 @@ await Assert.ThrowsAsync( [Fact] public async Task ExistingDefaultCallsRemainUnambiguous() { - // `default` has to keep binding to the pre-existing params string[] and CancellationToken overloads - // rather than to the new options overloads, otherwise adding those overloads is a source-breaking change. - // The compiler proves the binding; these assertions prove the bound overloads still behave as before. Assert.Throws(() => DistributedApplicationTestingBuilder.Create(default!)); await using var genericBuilder = @@ -207,227 +146,14 @@ await DistributedApplicationTestingBuilder.CreateAsync( Assert.Single(typeOptionsBuilder.Services, descriptor => descriptor.ServiceType == typeof(DashboardServiceHost)); } - [Fact] - public async Task BuildAsyncWithPreCanceledTokenDoesNotReleaseAppHost() - { - var probe = TestingAppHostBuildProbe.Create(); - var builder = - await DistributedApplicationTestingBuilder.CreateAsync( - [$"--block-apphost-build={probe.Id}"]); - try - { - using var cancellationTokenSource = new CancellationTokenSource(); - cancellationTokenSource.Cancel(); - - await Assert.ThrowsAnyAsync( - () => builder.BuildAsync(cancellationTokenSource.Token)); - await Assert.ThrowsAsync( - () => probe.BuildEntered.WaitAsync(TimeSpan.FromMilliseconds(500))); - } - finally - { - probe.ContinueBuilding(); - await builder.DisposeAsync(); - probe.Dispose(); - } - } - - [Fact] - public async Task BuildAsyncCancellationAfterReleaseReturnsPromptlyAndDisposesBuiltApplication() - { - var probe = TestingAppHostBuildProbe.Create(); - var builder = - await DistributedApplicationTestingBuilder.CreateAsync( - [$"--block-apphost-build={probe.Id}"]); - try - { - using var cancellationTokenSource = new CancellationTokenSource(); - var buildTask = builder.BuildAsync(cancellationTokenSource.Token); - await probe.BuildEntered.DefaultTimeout(); - Assert.False(buildTask.IsCompleted); - - cancellationTokenSource.Cancel(); - await Assert.ThrowsAnyAsync(() => buildTask.DefaultTimeout()); - - // The AppHost is still blocked inside Build(), so observing cancellation here proves BuildAsync - // returned without waiting for the application it already released. - Assert.False(probe.ApplicationDisposed.IsCompleted); - - probe.ContinueBuilding(); - - // Promptness is already proven by the assertion above. Reclaiming the late application goes through - // DistributedApplicationFactory.DisposeAsync, which first waits for the released AppHost entry point to - // exit under the host's shutdown timeout, so this leg needs a budget larger than DefaultTimeout's 5s. - await probe.ApplicationDisposed.WaitAsync(TimeSpan.FromSeconds(60)); - } - finally - { - probe.ContinueBuilding(); - await builder.DisposeAsync(); - probe.Dispose(); - } - } - - [Fact] - public async Task BuildAsyncCancellationFollowedByDisposeStillDisposesLateApplication() - { - var probe = TestingAppHostBuildProbe.Create(); - var builder = - await DistributedApplicationTestingBuilder.CreateAsync( - [$"--block-apphost-build={probe.Id}"]); - try - { - using var cancellationTokenSource = new CancellationTokenSource(); - var buildTask = builder.BuildAsync(cancellationTokenSource.Token); - await probe.BuildEntered.DefaultTimeout(); - - cancellationTokenSource.Cancel(); - await Assert.ThrowsAnyAsync(() => buildTask.DefaultTimeout()); - - // Disposing before the AppHost finishes building is the ordinary `await using` sequence. The - // application arrives after disposal has already claimed the factory, so nothing the caller holds - // can tear it down; the factory has to reclaim it. - await builder.DisposeAsync().DefaultTimeout(); - - probe.ContinueBuilding(); - - await probe.ApplicationDisposed.DefaultTimeout(); - } - finally - { - probe.ContinueBuilding(); - await builder.DisposeAsync(); - probe.Dispose(); - } - } - - [Fact] - public async Task BuildAsyncCancellationPreservesOperationCanceledExceptionWhenAppHostLaterFails() - { - var probe = TestingAppHostBuildProbe.Create(); - var builder = - await DistributedApplicationTestingBuilder.CreateAsync( - [$"--block-apphost-build={probe.Id}", "--crash-after-build"]); - try - { - using var cancellationTokenSource = new CancellationTokenSource(); - var buildTask = builder.BuildAsync(cancellationTokenSource.Token); - await probe.BuildEntered.DefaultTimeout(); - - cancellationTokenSource.Cancel(); - probe.ContinueBuilding(); - await probe.EntryPointFailure.DefaultTimeout(); - - await Assert.ThrowsAnyAsync(() => buildTask); - } - finally - { - probe.ContinueBuilding(); - await builder.DisposeAsync(); - probe.Dispose(); - } - } - - [Fact] - public async Task BuildAsyncAfterBuilderDisposedThrowsObjectDisposedException() - { - var builder = await DistributedApplicationTestingBuilder.CreateAsync(); - await builder.DisposeAsync(); - - var exception = await Assert.ThrowsAsync(() => builder.BuildAsync()); - - Assert.Equal(nameof(IDistributedApplicationTestingBuilder), exception.ObjectName); - } - - [Fact] - public async Task BuildAsyncAfterCancellationAbandonedTheApplicationIsRejected() - { - var probe = TestingAppHostBuildProbe.Create(); - var builder = - await DistributedApplicationTestingBuilder.CreateAsync( - [$"--block-apphost-build={probe.Id}"]); - try - { - using var cancellationTokenSource = new CancellationTokenSource(); - var buildTask = builder.BuildAsync(cancellationTokenSource.Token); - await probe.BuildEntered.DefaultTimeout(); - - cancellationTokenSource.Cancel(); - await Assert.ThrowsAnyAsync(() => buildTask.DefaultTimeout()); - - // No caller was left waiting, so the application the AppHost is still building belongs to the - // background reclaim. Handing it to a retry would give that caller an application being disposed. - // The budget only bounds a regression: rejection happens before this token can be observed. - using var retryTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var exception = await Assert.ThrowsAsync( - () => builder.BuildAsync(retryTimeout.Token)); - - Assert.Equal( - "The application was abandoned because the BuildAsync call that released the AppHost was canceled, " + - "and it is being disposed. An AppHost entry point builds a single application, so building again " + - "requires a new testing builder.", - exception.Message); - } - finally - { - probe.ContinueBuilding(); - await builder.DisposeAsync(); - probe.Dispose(); - } - } - - [Fact] - public async Task BuildAsyncCancellationLeavesTheApplicationToAConcurrentBuild() - { - var probe = TestingAppHostBuildProbe.Create(); - var builder = - await DistributedApplicationTestingBuilder.CreateAsync( - [$"--block-apphost-build={probe.Id}"]); - try - { - using var cancellationTokenSource = new CancellationTokenSource(); - var canceledBuildTask = builder.BuildAsync(cancellationTokenSource.Token); - await probe.BuildEntered.DefaultTimeout(); - - // BuildAsync registers the caller as waiting before it reaches its first await, so by the time this - // returns a task the surviving build is already accounted for by the cancellation below. - var survivingBuildTask = builder.BuildAsync(CancellationToken.None); - - cancellationTokenSource.Cancel(); - await Assert.ThrowsAnyAsync(() => canceledBuildTask.DefaultTimeout()); - - probe.ContinueBuilding(); - var application = await survivingBuildTask.WaitAsync(TimeSpan.FromSeconds(60)); - - // A background reclaim would dispose the factory as soon as the application arrived, which flips the - // builder into its disposed state. Owning the application means the builder still hands it back. - await Task.Delay(TimeSpan.FromSeconds(2)); - var rebuiltApplication = await builder.BuildAsync().DefaultTimeout(); - Assert.Same(application.Services, rebuiltApplication.Services); - Assert.False(probe.ApplicationDisposed.IsCompleted); - } - finally - { - probe.ContinueBuilding(); - await builder.DisposeAsync(); - probe.Dispose(); - } - } - [Theory] - [InlineData(CreationSurface.Generic, "--operation", "publish")] - [InlineData(CreationSurface.Generic, "--publisher", "manifest")] - [InlineData(CreationSurface.Type, "--operation", "publish")] - [InlineData(CreationSurface.Type, "--publisher", "manifest")] - [InlineData(CreationSurface.AdHoc, "--operation", "publish")] - [InlineData(CreationSurface.AdHoc, "--publisher", "manifest")] - public async Task DashboardTestingIsRejectedInPublishMode( - CreationSurface creationSurface, - string argumentName, - string argumentValue) + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + [InlineData(CreationSurface.AdHoc)] + public async Task DashboardTestingIsRejectedInPublishMode(CreationSurface creationSurface) { var exception = await Assert.ThrowsAsync( - () => CreatePublishBuilderAsync(creationSurface, [argumentName, argumentValue])); + () => CreatePublishBuilderAsync(creationSurface)); Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); } @@ -435,9 +161,6 @@ public async Task DashboardTestingIsRejectedInPublishMode( [Fact] public async Task DashboardEnabledThroughConfigureBuilderKeepsCallerConfiguration() { - // DistributedApplicationOptions.DisableDashboard = false is the older spelling of "run the dashboard" and it - // predates DistributedApplicationTestingBuilderOptions. It is not the hardened dashboard testing mode, so - // configuration the caller supplied at creation has to survive rather than being rewritten underneath them. await using var builder = await DistributedApplicationTestingBuilder.CreateAsync( [$"--{DashboardUnsecuredAllowAnonymous}=true", $"--{AspNetCoreUrls}=http://127.0.0.1:12345"], (options, _) => options.DisableDashboard = false); @@ -446,30 +169,15 @@ public async Task DashboardEnabledThroughConfigureBuilderKeepsCallerConfiguratio Assert.Equal("http://127.0.0.1:12345", builder.Configuration[AspNetCoreUrls]); } - [Fact] - public async Task DashboardEnabledThroughConfigureBuilderIsNotRejectedInPublishMode() - { - // Publish mode never adds a dashboard resource, so the older spelling has always been ignored there. - // Rejecting it would break existing callers that publish with DisableDashboard = false, and dropping their - // arguments while resolving dashboard settings would silently turn a publish run into a run-mode run. - await using var builder = await DistributedApplicationTestingBuilder.CreateAsync( - ["--publisher", "manifest"], - (options, _) => options.DisableDashboard = false); - - Assert.True(builder.ExecutionContext.IsPublishMode); - } - [Theory] [InlineData(CreationSurface.Generic, "--clear-apphost-browser-token")] [InlineData(CreationSurface.Generic, "--null-apphost-browser-token")] [InlineData(CreationSurface.Type, "--clear-apphost-browser-token")] [InlineData(CreationSurface.Type, "--null-apphost-browser-token")] - public async Task DashboardTestingRestoresTheBrowserTokenAnAppHostCleared(CreationSurface creationSurface, string appHostFlag) + public async Task DashboardTestingRestoresTheBrowserTokenAnAppHostCleared( + CreationSurface creationSurface, + string appHostFlag) { - // DistributedApplicationBuilder freezes the token into AppHost:BrowserToken during construction, but - // DashboardOptions does not read that key until the application starts. AppHost code runs between those - // points, so without the restore it can blank the key and DashboardEventHandlers launches the dashboard - // with Unsecured frontend authentication, defeating the authenticated default this opt-in promises. await using var builder = await CreateDashboardBuilderAsync(creationSurface, [appHostFlag]); await using var app = await builder.BuildAsync(); @@ -482,10 +190,8 @@ public async Task DashboardTestingRestoresTheBrowserTokenAnAppHostCleared(Creati [InlineData(CreationSurface.Type)] public async Task DashboardTestingLeavesTheBrowserTokenToTheCallersBuilder(CreationSurface creationSurface) { - // The restore above runs before the caller sees the builder, so a test that deliberately wants the - // anonymous dashboard keeps the documented escape hatch through the returned builder. await using var builder = await CreateDashboardBuilderAsync(creationSurface, []); - builder.Configuration["AppHost:BrowserToken"] = ""; + builder.Configuration[AppHostBrowserToken] = ""; await using var app = await builder.BuildAsync(); var dashboardOptions = app.Services.GetRequiredService>().Value; @@ -497,12 +203,10 @@ public async Task DashboardTestingLeavesTheBrowserTokenToTheCallersBuilder(Creat [InlineData(CreationSurface.Generic, "--clear-apphost-resource-service-key")] [InlineData(CreationSurface.Type, "--unsecure-apphost-resource-service")] [InlineData(CreationSurface.Type, "--clear-apphost-resource-service-key")] - public async Task DashboardTestingRestoresResourceServiceAuthenticationAnAppHostDowngraded(CreationSurface creationSurface, string appHostFlag) + public async Task DashboardTestingRestoresResourceServiceAuthenticationAnAppHostDowngraded( + CreationSurface creationSurface, + string appHostFlag) { - // The resource-service credential sits in the same construction-to-start window as the browser token. - // DashboardServiceHost binds AppHost:ResourceService when the application starts, and - // ResourceServiceApiKeyAuthenticationHandler authenticates every request once the mode is not ApiKey, so an - // AppHost that downgrades either half would otherwise expose the resource model on the loopback endpoint. await using var builder = await CreateDashboardBuilderAsync(creationSurface, [appHostFlag]); Assert.Equal(nameof(ResourceServiceAuthMode.ApiKey), builder.Configuration[AppHostResourceServiceAuthMode]); @@ -514,8 +218,6 @@ public async Task DashboardTestingRestoresResourceServiceAuthenticationAnAppHost [InlineData(CreationSurface.Type)] public async Task DashboardTestingGeneratesAFreshResourceServiceApiKeyPerApplication(CreationSurface creationSurface) { - // An ambient ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY is what the builder adopts when configuration supplies - // one, which on a CI agent would hand every test application the same resource-service credential. const string SharedKey = "shared-resource-service-key"; string[] args = [$"--{DashboardResourceServiceApiKey}={SharedKey}"]; @@ -532,69 +234,6 @@ public async Task DashboardTestingGeneratesAFreshResourceServiceApiKeyPerApplica Assert.NotEqual(firstKey, secondKey); } - [Fact] - public async Task ConcurrentDisposeAsyncRunsTeardownOnce() - { - // DistributedApplicationFactory.DisposeAsync claims disposal with an interlocked exchange. A guard that only - // reads the disposal cancellation token lets concurrent disposers all pass, because that token is cancelled - // after the guard, and each of them then stops and disposes the same application. Racing several disposers - // is the only way to exercise the claim; a sequential second call is filtered by either implementation. - var hostStopCount = new StrongBox(); - var builder = await DistributedApplicationTestingBuilder.CreateAsync(); - CountHostStops(builder, hostStopCount); - - await builder.BuildAsync().DefaultTimeout(); - - // The window between reading the guard and cancelling the disposal token is only a few instructions wide, - // so the disposers run on dedicated threads released by a barrier. Thread pool continuations arrive too far - // apart to land in that window reliably. - const int DisposerCount = 8; - var disposals = new Task[DisposerCount]; - var threads = new Thread[DisposerCount]; - using var start = new Barrier(DisposerCount); - for (var i = 0; i < DisposerCount; i++) - { - var index = i; - threads[index] = new Thread(() => - { - start.SignalAndWait(); - - // DisposeAsync runs synchronously on this thread up to its first suspension point, which is past - // the guard, so the returned task is only awaited once every disposer has been through it. - disposals[index] = builder.DisposeAsync().AsTask(); - }) - { - IsBackground = true, - Name = $"Disposer-{index}" - }; - threads[index].Start(); - } - - foreach (var thread in threads) - { - Assert.True(thread.Join(TimeSpan.FromSeconds(60))); - } - - // A disposer that ran teardown against an application another disposer had already disposed surfaces its - // exception here. Disposal waits for the released AppHost entry point to exit under the host's shutdown - // timeout, so this needs a budget larger than DefaultTimeout's 5s. - await Task.WhenAll(disposals).WaitAsync(TimeSpan.FromSeconds(60)); - - Assert.Equal(1, hostStopCount.Value); - } - - private static void CountHostStops(IDistributedApplicationTestingBuilder builder, StrongBox stopCount) - { - // The testing factory registers the IHost that HostApplicationBuilder.Build() resolves and that - // DistributedApplication.StopAsync() delegates to. Wrapping the last registration counts every teardown pass - // through DistributedApplicationFactory.DisposeAsync, including passes an unclaimed guard would allow. - var hostDescriptor = builder.Services.Last( - descriptor => descriptor.ServiceType == typeof(IHost) && descriptor.ServiceKey is null); - builder.Services.Remove(hostDescriptor); - builder.Services.AddSingleton( - sp => new StopCountingHost((IHost)hostDescriptor.ImplementationFactory!(sp), stopCount)); - } - private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() { return new() @@ -606,19 +245,13 @@ private static DistributedApplicationTestingBuilderOptions CreateDashboardOption private static void AssertDashboardTestingDefaults(IDistributedApplicationTestingBuilder builder) { Assert.Equal("true", builder.Configuration["DcpPublisher:RandomizePorts"]); - - // Blank is how the product spells "assign me a free port", so these must stay empty rather than - // carrying an explicit :0, which would be a literal fixed port. Assert.Equal(string.Empty, builder.Configuration[AspNetCoreUrls]); Assert.Equal(string.Empty, builder.Configuration[DashboardOtlpGrpcEndpointUrl]); Assert.Equal(string.Empty, builder.Configuration[DashboardOtlpHttpEndpointUrl]); - Assert.Equal("http://127.0.0.1:0", builder.Configuration[ResourceServiceEndpointUrl]); Assert.Equal("false", builder.Configuration[DashboardUnsecuredAllowAnonymous]); Assert.Equal("false", builder.Configuration[InteractivityEnabled]); - // The token has to survive all the way into AppHost:BrowserToken, which is the key the dashboard - // actually validates against and the one GetDashboardLoginUrlAsync hands back. var browserToken = builder.Configuration[DashboardFrontendBrowserToken]; Assert.NotEmpty(browserToken!); Assert.Equal(browserToken, builder.Configuration[AppHostBrowserToken]); @@ -639,15 +272,20 @@ private static async Task CreateDashboard }; } - private static async Task CreatePublishBuilderAsync(CreationSurface creationSurface, string[] args) + private static async Task CreatePublishBuilderAsync(CreationSurface creationSurface) { var options = CreateDashboardOptions(); var builder = creationSurface switch { - CreationSurface.Generic => await DistributedApplicationTestingBuilder.CreateAsync(options, args), - CreationSurface.Type => await DistributedApplicationTestingBuilder.CreateAsync(typeof(Projects.TestingAppHost1_AppHost), options, args), - CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, args), + CreationSurface.Generic => await DistributedApplicationTestingBuilder.CreateAsync( + options, + ["--publisher", "manifest"]), + CreationSurface.Type => await DistributedApplicationTestingBuilder.CreateAsync( + typeof(Projects.TestingAppHost1_AppHost), + options, + ["--publisher", "manifest"]), + CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, ["--publisher", "manifest"]), _ => throw new ArgumentOutOfRangeException(nameof(creationSurface)) }; @@ -660,30 +298,4 @@ public enum CreationSurface Type, AdHoc } - - private sealed class StopCountingHost(IHost innerHost, StrongBox stopCount) : IHost, IAsyncDisposable - { - public IServiceProvider Services => innerHost.Services; - - public void Dispose() => innerHost.Dispose(); - - public async ValueTask DisposeAsync() - { - if (innerHost is IAsyncDisposable asyncDisposable) - { - await asyncDisposable.DisposeAsync(); - return; - } - - innerHost.Dispose(); - } - - public Task StartAsync(CancellationToken cancellationToken = default) => innerHost.StartAsync(cancellationToken); - - public Task StopAsync(CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref stopCount.Value); - return innerHost.StopAsync(cancellationToken); - } - } } diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index d75a7a5abe8..52472a9a402 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -9,25 +9,6 @@ var builder = DistributedApplication.CreateBuilder(args); -var buildProbeArgument = args.FirstOrDefault( - argument => argument.StartsWith("--block-apphost-build=", StringComparison.Ordinal)); -if (buildProbeArgument is not null) -{ - var buildProbeId = buildProbeArgument["--block-apphost-build=".Length..]; - TestingAppHostBuildProbe.Configure(builder, buildProbeId); -} - -if (args.Contains("--override-dashboard-testing-defaults")) -{ - builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; - builder.Configuration["ASPNETCORE_URLS"] = "http://127.0.0.1:12345"; - builder.Configuration["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://127.0.0.1:12346"; - builder.Configuration["ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://127.0.0.1:12347"; - builder.Configuration["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "http://127.0.0.1:12348"; - builder.Configuration["ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"] = "true"; - builder.Configuration["ASPIRE_INTERACTIVITY_ENABLED"] = "true"; -} - if (args.Contains("--clear-apphost-browser-token")) { // The frozen token is the one dashboard setting an AppHost could still change after the testing builder had @@ -103,12 +84,6 @@ if (args.Contains("--crash-after-build")) { - if (buildProbeArgument is not null) - { - var buildProbeId = buildProbeArgument["--block-apphost-build=".Length..]; - TestingAppHostBuildProbe.SignalEntryPointFailure(buildProbeId); - } - throw new InvalidOperationException("Crashing: after-build."); } diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs deleted file mode 100644 index 10f874eb924..00000000000 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostBuildProbe.cs +++ /dev/null @@ -1,147 +0,0 @@ -// 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 Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -public static class TestingAppHostBuildProbe -{ - private static readonly ConcurrentDictionary s_probes = new(); - - public static Probe Create() - { - var id = Guid.NewGuid().ToString("N"); - var state = new ProbeState(); - if (!s_probes.TryAdd(id, state)) - { - throw new InvalidOperationException($"Could not create build probe '{id}'."); - } - - return new Probe(id, state); - } - - internal static void Configure(IDistributedApplicationBuilder builder, string id) - { - if (!s_probes.TryGetValue(id, out var state)) - { - throw new InvalidOperationException($"Build probe '{id}' was not registered."); - } - - // The testing factory pauses in its OnBuilding callback. Blocking the first resource-name read after that - // callback creates a deterministic window where BuildAsync has released the AppHost but Build has not finished. - builder.Resources.Add(new BlockingResource($"build-probe-{id}", builder.Services, state)); - state.Arm(); - } - - internal static void SignalEntryPointFailure(string id) - { - if (!s_probes.TryGetValue(id, out var state)) - { - throw new InvalidOperationException($"Build probe '{id}' was not registered."); - } - - state.EntryPointFailure.TrySetResult(); - } - - public sealed class Probe : IDisposable - { - private readonly ProbeState _state; - - internal Probe(string id, ProbeState state) - { - Id = id; - _state = state; - } - - public string Id { get; } - - public Task BuildEntered => _state.BuildEntered.Task; - - public Task EntryPointFailure => _state.EntryPointFailure.Task; - - public Task ApplicationDisposed => _state.ApplicationDisposed.Task; - - public void ContinueBuilding() - { - _state.ContinueBuilding.TrySetResult(); - } - - public void Dispose() - { - ContinueBuilding(); - s_probes.TryRemove(Id, out _); - } - } - - internal sealed class ProbeState - { - private int _armed; - private int _buildObserved; - - public TaskCompletionSource BuildEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public TaskCompletionSource ContinueBuilding { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public TaskCompletionSource EntryPointFailure { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public TaskCompletionSource ApplicationDisposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public void Arm() - { - Volatile.Write(ref _armed, 1); - } - - public bool TryObserveBuild() - { - return Volatile.Read(ref _armed) == 1 && - Interlocked.CompareExchange(ref _buildObserved, 1, 0) == 0; - } - } - - private sealed class BlockingResource( - string name, - IServiceCollection services, - ProbeState state) : Resource(name) - { - public override string Name - { - get - { - if (state.TryObserveBuild()) - { - var hostDescriptor = services.Single( - descriptor => descriptor.ServiceType == typeof(IHost) && descriptor.ServiceKey is null); - var hostFactory = hostDescriptor.ImplementationFactory - ?? throw new InvalidOperationException("The AppHost IHost registration did not use a factory."); - - // Resolve the tracker as part of IHost creation so disposal proof does not depend on the - // application reaching StartAsync after a canceled build. - services.Remove(hostDescriptor); - services.AddSingleton(_ => new ProbeDisposalTracker(state)); - services.Add(ServiceDescriptor.Describe( - typeof(IHost), - serviceProvider => - { - _ = serviceProvider.GetRequiredService(); - return hostFactory(serviceProvider); - }, - hostDescriptor.Lifetime)); - - state.BuildEntered.TrySetResult(); - state.ContinueBuilding.Task.GetAwaiter().GetResult(); - } - - return base.Name; - } - } - } - - private sealed class ProbeDisposalTracker(ProbeState state) : IDisposable - { - public void Dispose() - { - state.ApplicationDisposed.TrySetResult(); - } - } -} From d29d2558872740b67d51930128e6a6fd3c9f3db9 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 18:37:27 -0400 Subject: [PATCH 12/21] Address remaining dashboard testing review feedback Dispose the suspended factory when builder creation fails, and keep dashboard runtime tests available on supported Podman hosts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8698944a-ea7e-4b37-af6a-925165a54f2b --- .../DistributedApplicationTestingBuilder.cs | 10 ++- .../DashboardLoginUrlTests.cs | 12 ++-- .../DashboardTestingBuilderTests.cs | 23 ++++-- .../TestingAppHost1.AppHost/Program.cs | 2 + .../TestingAppHostEntryPointProbe.cs | 70 +++++++++++++++++++ 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index a97d51de2b6..fe2e52954b9 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -191,7 +191,15 @@ private static async Task CreateAsyncCore ArgumentNullException.ThrowIfNull(configureBuilder, nameof(configureBuilder)); var factory = new SuspendingDistributedApplicationFactory(entryPoint, args, testingOptions, configureBuilder); - return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); + try + { + return await factory.CreateBuilderAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + await factory.DisposeAsync().ConfigureAwait(false); + throw; + } } /// diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs index 88f29cee7b0..8cef730fcb3 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs @@ -15,7 +15,7 @@ namespace Aspire.Hosting.Testing.Tests; public class DashboardLoginUrlTests { [Fact] - [RequiresFeature(TestFeature.Docker)] + [RequiresFeature(TestFeature.ContainerRuntime)] public async Task GetDashboardLoginUrlAsyncAuthenticatesDashboardBrowser() { await using var builder = await CreateDashboardBuilderAsync(); @@ -57,7 +57,7 @@ public async Task GetDashboardLoginUrlAsyncAuthenticatesDashboardBrowser() } [Fact] - [RequiresFeature(TestFeature.Docker)] + [RequiresFeature(TestFeature.ContainerRuntime)] public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() { await using var firstBuilder = await CreateDashboardBuilderAsync(); @@ -101,7 +101,7 @@ public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() } [Fact] - [RequiresFeature(TestFeature.Docker)] + [RequiresFeature(TestFeature.ContainerRuntime)] public async Task GetDashboardLoginUrlAsyncThrowsWhenDashboardIsDisabled() { var builder = DistributedApplicationTestingBuilder.Create(); @@ -115,7 +115,7 @@ public async Task GetDashboardLoginUrlAsyncThrowsWhenDashboardIsDisabled() } [Fact] - [RequiresFeature(TestFeature.Docker)] + [RequiresFeature(TestFeature.ContainerRuntime)] public async Task GetDashboardLoginUrlAsyncThrowsWhenDashboardAllowsAnonymousAccess() { await using var builder = await CreateDashboardBuilderAsync(); @@ -154,7 +154,7 @@ public async Task GetDashboardLoginUrlAsyncThrowsInPublishMode() } [Fact] - [RequiresFeature(TestFeature.Docker)] + [RequiresFeature(TestFeature.ContainerRuntime)] public async Task GetDashboardLoginUrlAsyncPreservesTerminalDashboardFailure() { var missingDashboardPath = Path.Combine( @@ -175,7 +175,7 @@ public async Task GetDashboardLoginUrlAsyncPreservesTerminalDashboardFailure() } [Fact] - [RequiresFeature(TestFeature.Docker)] + [RequiresFeature(TestFeature.ContainerRuntime)] public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLogs() { await using var builder = await CreateDashboardBuilderAsync(); diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs index cfada7b341f..a1a4a8284df 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardTestingBuilderTests.cs @@ -158,6 +158,20 @@ public async Task DashboardTestingIsRejectedInPublishMode(CreationSurface creati Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); } + [Theory] + [InlineData(CreationSurface.Generic)] + [InlineData(CreationSurface.Type)] + public async Task DashboardTestingPublishModeFailureStopsAppHostEntryPoint(CreationSurface creationSurface) + { + using var probe = TestingAppHostEntryPointProbe.Create(); + + var exception = await Assert.ThrowsAsync( + () => CreatePublishBuilderAsync(creationSurface, $"--entry-point-exit-probe={probe.Id}")); + + Assert.Equal(TestingResources.DashboardTestingPublishModeExceptionMessage, exception.Message); + await probe.Exited.WaitAsync(TimeSpan.FromSeconds(10)); + } + [Fact] public async Task DashboardEnabledThroughConfigureBuilderKeepsCallerConfiguration() { @@ -272,20 +286,21 @@ private static async Task CreateDashboard }; } - private static async Task CreatePublishBuilderAsync(CreationSurface creationSurface) + private static async Task CreatePublishBuilderAsync(CreationSurface creationSurface, params string[] additionalArgs) { var options = CreateDashboardOptions(); + string[] args = [.. additionalArgs, "--publisher", "manifest"]; var builder = creationSurface switch { CreationSurface.Generic => await DistributedApplicationTestingBuilder.CreateAsync( options, - ["--publisher", "manifest"]), + args), CreationSurface.Type => await DistributedApplicationTestingBuilder.CreateAsync( typeof(Projects.TestingAppHost1_AppHost), options, - ["--publisher", "manifest"]), - CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, ["--publisher", "manifest"]), + args), + CreationSurface.AdHoc => DistributedApplicationTestingBuilder.Create(options, args), _ => throw new ArgumentOutOfRangeException(nameof(creationSurface)) }; diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index 52472a9a402..68a0e735f13 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -7,6 +7,8 @@ #pragma warning disable ASPIRECERTIFICATES001 +using var entryPointProbe = TestingAppHostEntryPointProbe.Track(args); + var builder = DistributedApplication.CreateBuilder(args); if (args.Contains("--clear-apphost-browser-token")) diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs new file mode 100644 index 00000000000..04ffb19099e --- /dev/null +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs @@ -0,0 +1,70 @@ +// 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; + +public static class TestingAppHostEntryPointProbe +{ + private const string ArgumentPrefix = "--entry-point-exit-probe="; + private static readonly ConcurrentDictionary s_probes = new(); + + public static Probe Create() + { + var id = Guid.NewGuid().ToString("N"); + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!s_probes.TryAdd(id, exited)) + { + throw new InvalidOperationException($"Could not create entry-point probe '{id}'."); + } + + return new Probe(id, exited.Task); + } + + public static IDisposable Track(string[] args) + { + // The test passes the probe as: + // --entry-point-exit-probe=<32-character GUID> + var argument = args.FirstOrDefault(arg => arg.StartsWith(ArgumentPrefix, StringComparison.Ordinal)); + if (argument is null) + { + return EmptyDisposable.Instance; + } + + var id = argument[ArgumentPrefix.Length..]; + if (!s_probes.TryGetValue(id, out var exited)) + { + throw new InvalidOperationException($"Entry-point probe '{id}' was not registered."); + } + + return new ExitSignal(exited); + } + + public sealed class Probe(string id, Task exited) : IDisposable + { + public string Id { get; } = id; + + public Task Exited { get; } = exited; + + public void Dispose() + { + s_probes.TryRemove(Id, out _); + } + } + + private sealed class ExitSignal(TaskCompletionSource exited) : IDisposable + { + public void Dispose() + { + exited.TrySetResult(); + } + } + + private sealed class EmptyDisposable : IDisposable + { + public static EmptyDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } +} From 9e532fcb1cd7d96425749d67b628ab74a1e1cfdd Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 14 Aug 2026 19:14:04 -0400 Subject: [PATCH 13/21] Suppress dashboard tokens in forwarded test logs Propagate a testing-only output setting to the child dashboard and wait for its forwarded summary in the regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8698944a-ea7e-4b37-af6a-925165a54f2b --- .../DashboardWebApplication.cs | 5 ++++- .../DistributedApplicationTestingBuilder.cs | 13 ++++++------ src/Shared/KnownConfigNames.cs | 1 + .../DashboardLoginUrlTests.cs | 20 ++++++++++++++++--- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 421fa1b1a8b..1bee7bc83a5 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -529,7 +529,10 @@ public DashboardWebApplication( private void PrintSummary(ResolvedEndpointInfo? frontendEndpointInfo) { var options = _app.Services.GetRequiredService>().CurrentValue; - var token = options.Frontend.AuthMode == FrontendAuthMode.BrowserToken ? options.Frontend.BrowserToken : null; + var suppressBrowserToken = _app.Configuration.GetBool(KnownConfigNames.DashboardSuppressBrowserTokenInOutput) ?? false; + var token = !suppressBrowserToken && options.Frontend.AuthMode == FrontendAuthMode.BrowserToken + ? options.Frontend.BrowserToken + : null; var frontendAddress = frontendEndpointInfo?.GetResolvedAddress(replaceIPAnyWithLocalhost: true); var otlpGrpcAddress = _otlpServiceGrpcEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); var otlpHttpAddress = _otlpServiceHttpEndPointAccessor?.Invoke().GetResolvedAddress(replaceIPAnyWithLocalhost: true); diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index fe2e52954b9..ee67fa7fe3a 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -426,13 +426,12 @@ private static void AddDashboardTestingConfiguration(IConfigurationBuilder confi [KnownConfigNames.DashboardUnsecuredAllowAnonymous] = "false", [KnownConfigNames.InteractivityEnabled] = "false", - // The AppHost's startup summary otherwise writes ".../login?t={token}" through - // ILogger, which under a test host is test and CI output. The token is a - // live credential for this application's dashboard, and emitting it there is both the opposite of - // this opt-in's "no login URL in test output" behavior and a credential in a retained log artifact. - // Tests get the same URL from GetDashboardLoginUrlAsync. Only the login URL is withheld; the - // dashboard and OTLP endpoint lines are unaffected. - ["AppHost:SuppressDashboardLoginUrlInStartupSummary"] = "true" + // 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 GetDashboardLoginUrlAsync instead. + ["AppHost:SuppressDashboardLoginUrlInStartupSummary"] = "true", + [KnownConfigNames.DashboardSuppressBrowserTokenInOutput] = "true" }); } diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index 4838d69ded2..a853aa915e9 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -10,6 +10,7 @@ internal static class KnownConfigNames public const string DashboardOtlpGrpcEndpointUrl = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"; public const string DashboardOtlpHttpEndpointUrl = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"; public const string DashboardFrontendBrowserToken = "ASPIRE_DASHBOARD_FRONTEND_BROWSERTOKEN"; + public const string DashboardSuppressBrowserTokenInOutput = "ASPIRE_DASHBOARD_SUPPRESS_BROWSER_TOKEN_IN_OUTPUT"; public const string DashboardResourceServiceClientApiKey = "ASPIRE_DASHBOARD_RESOURCESERVICE_APIKEY"; public const string DashboardUnsecuredAllowAnonymous = "ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS"; public const string DashboardCorsAllowedOrigins = "ASPIRE_DASHBOARD_CORS_ALLOWED_ORIGINS"; diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs index 8cef730fcb3..f1e713ef816 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs @@ -190,11 +190,18 @@ public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLog var token = dashboardUri.Query["?t=".Length..]; Assert.NotEmpty(token); + // Resource log forwarding is asynchronous. Wait for the child dashboard's summary, which is written after + // its separate login URL line, so the assertion covers both the AppHost and dashboard-process output. + while (!logCollector.GetSnapshot().Any(record => + record.Category?.EndsWith(".Resources.aspire-dashboard", StringComparison.Ordinal) == true && + GetLogText(record).Contains("Aspire Dashboard", StringComparison.Ordinal))) + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationTokenSource.Token); + } + var written = string.Join( Environment.NewLine, - logCollector.GetSnapshot().Select(record => record.Message + " " + record.StructuredState?.Aggregate( - string.Empty, - (accumulated, pair) => accumulated + " " + pair.Value))); + logCollector.GetSnapshot().Select(GetLogText)); Assert.False( written.Contains(token, StringComparison.Ordinal), @@ -202,6 +209,13 @@ public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLog Assert.Contains("Aspire Dashboard", written, StringComparison.Ordinal); } + private static string GetLogText(FakeLogRecord record) + { + return record.Message + " " + record.StructuredState?.Aggregate( + string.Empty, + (accumulated, pair) => accumulated + " " + pair.Value); + } + private static Task CreateDashboardBuilderAsync() { return DistributedApplicationTestingBuilder.CreateAsync( From 38ced12ab51dc2f452ce079727cb4aa592dee129 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 17 Aug 2026 19:41:34 -0400 Subject: [PATCH 14/21] Support running the dashboard from tests --- ...utedApplicationHostingTestingExtensions.cs | 52 ++++-- .../DistributedApplicationTestingBuilder.cs | 2 +- ...ributedApplicationTestingBuilderOptions.cs | 12 +- .../Properties/Resources.Designer.cs | 27 +-- .../Properties/Resources.resx | 15 +- .../Properties/xlf/Resources.cs.xlf | 29 ++- .../Properties/xlf/Resources.de.xlf | 29 ++- .../Properties/xlf/Resources.es.xlf | 29 ++- .../Properties/xlf/Resources.fr.xlf | 29 ++- .../Properties/xlf/Resources.it.xlf | 29 ++- .../Properties/xlf/Resources.ja.xlf | 29 ++- .../Properties/xlf/Resources.ko.xlf | 29 ++- .../Properties/xlf/Resources.pl.xlf | 29 ++- .../Properties/xlf/Resources.pt-BR.xlf | 29 ++- .../Properties/xlf/Resources.ru.xlf | 29 ++- .../Properties/xlf/Resources.tr.xlf | 29 ++- .../Properties/xlf/Resources.zh-Hans.xlf | 29 ++- .../Properties/xlf/Resources.zh-Hant.xlf | 29 ++- .../Dashboard/DashboardOptions.cs | 2 +- .../Aspire.Hosting.Testing.Tests.csproj | 6 + ...dLoginUrlTests.cs => DashboardUrlTests.cs} | 167 +++++++++++++++--- .../TestingAppHost1.AppHost/Program.cs | 4 + .../TestingAppHostEntryPointProbe.cs | 75 ++++++++ 23 files changed, 447 insertions(+), 292 deletions(-) rename tests/Aspire.Hosting.Testing.Tests/{DashboardLoginUrlTests.cs => DashboardUrlTests.cs} (52%) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs index 93e8e4d480b..680a31fa69e 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs @@ -16,12 +16,13 @@ public static class DistributedApplicationHostingTestingExtensions private const string DashboardResourceName = "aspire-dashboard"; /// - /// Gets the authenticated login URL for the running Aspire dashboard. + /// Gets the URL for the running Aspire dashboard. /// /// The distributed application. /// A token used to cancel the operation. /// - /// An absolute that authenticates the browser with the running dashboard. + /// An absolute that can be opened in a browser. The URL authenticates the browser when + /// authentication is enabled and otherwise points directly to the dashboard. /// /// /// @@ -30,17 +31,18 @@ public static class DistributedApplicationHostingTestingExtensions /// /// /// This method does not start the distributed application. Call - /// before requesting the dashboard login URL. + /// before requesting the dashboard URL. /// /// /// This method waits for the dashboard resource to become healthy. Pass a cancellation token when the wait must - /// be bounded. The returned URI contains an authentication credential and should be treated as sensitive. + /// be bounded. When authentication is enabled, the returned URI contains an authentication credential and should + /// be treated as sensitive. /// /// /// Thrown when is . /// /// Thrown when the application is in publish mode, the dashboard is disabled, the application has not started, - /// anonymous dashboard access is enabled, or a dashboard login URL is unavailable. + /// or a dashboard URL is unavailable. /// /// Thrown when the dashboard reaches a terminal failure state. /// Thrown when is canceled. @@ -56,11 +58,11 @@ public static class DistributedApplicationHostingTestingExtensions /// await using var app = await builder.BuildAsync(); /// await app.StartAsync(); /// - /// var dashboardLoginUrl = await app.GetDashboardLoginUrlAsync(); + /// var dashboardUrl = await app.GetDashboardUrlAsync(); /// /// - [AspireExportIgnore(Reason = "Dashboard URLs are only available to .NET test code and may contain authentication credentials.")] - public static async Task GetDashboardLoginUrlAsync( + [AspireExportIgnore(Reason = "Use the exported getDashboardUrl overload without a cancellation token.")] + public static async Task GetDashboardUrlAsync( this DistributedApplication app, CancellationToken cancellationToken = default) { @@ -69,7 +71,7 @@ public static async Task GetDashboardLoginUrlAsync( var executionContext = app.Services.GetRequiredService(); if (executionContext.IsPublishMode) { - throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlPublishModeExceptionMessage); + throw new InvalidOperationException(Properties.Resources.DashboardUrlPublishModeExceptionMessage); } var applicationOptions = app.Services.GetRequiredService(); @@ -78,7 +80,7 @@ public static async Task GetDashboardLoginUrlAsync( throw new InvalidOperationException(Properties.Resources.DashboardDisabledExceptionMessage); } - ThrowIfNotStarted(app, Properties.Resources.DashboardLoginUrlApplicationNotStartedExceptionMessage); + ThrowIfNotStarted(app, Properties.Resources.DashboardUrlApplicationNotStartedExceptionMessage); cancellationToken.ThrowIfCancellationRequested(); await app.ResourceNotifications.WaitForResourceHealthyAsync( @@ -90,27 +92,43 @@ await app.ResourceNotifications.WaitForResourceHealthyAsync( if (!applicationModel.Resources.TryGetByName(DashboardResourceName, out var resource) || resource is not IResourceWithEndpoints dashboardResource) { - throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlUnavailableExceptionMessage); + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); } var httpsEndpoint = dashboardResource.GetEndpoint("https"); var httpEndpoint = dashboardResource.GetEndpoint("http"); var dashboardEndpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint; - var browserToken = app.Services.GetRequiredService()["AppHost:BrowserToken"]; - if (string.IsNullOrEmpty(browserToken)) + if (!dashboardEndpoint.Exists) { - throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlAnonymousExceptionMessage); + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); } - if (!dashboardEndpoint.Exists || - !Uri.TryCreate($"{dashboardEndpoint.Url.TrimEnd('/')}/login?t={browserToken}", UriKind.Absolute, out var dashboardUri)) + var browserToken = app.Services.GetRequiredService()["AppHost:BrowserToken"]; + var dashboardUrl = string.IsNullOrEmpty(browserToken) + ? dashboardEndpoint.Url + : $"{dashboardEndpoint.Url.TrimEnd('/')}/login?t={Uri.EscapeDataString(browserToken)}"; + + if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri)) { - throw new InvalidOperationException(Properties.Resources.DashboardLoginUrlUnavailableExceptionMessage); + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); } return dashboardUri; } + /// + /// Gets the URL for the running Aspire dashboard. + /// + /// + /// An absolute that can be opened in a browser. When authentication is enabled, the URI + /// contains an authentication credential and should be treated as sensitive. + /// + [AspireExport("getDashboardUrl")] + internal static Task GetDashboardUrlAsyncExport(this DistributedApplication app) + { + return app.GetDashboardUrlAsync(default); + } + /// /// Creates an configured to communicate with the specified resource. /// diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs index ee67fa7fe3a..d98508f1ac0 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilder.cs @@ -429,7 +429,7 @@ private static void AddDashboardTestingConfiguration(IConfigurationBuilder confi // 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 GetDashboardLoginUrlAsync instead. + // Tests get the credential through GetDashboardUrlAsync instead. ["AppHost:SuppressDashboardLoginUrlInStartupSummary"] = "true", [KnownConfigNames.DashboardSuppressBrowserTokenInOutput] = "true" }); diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs index 19172091b67..02b5f034ee9 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs @@ -25,7 +25,7 @@ namespace Aspire.Hosting.Testing; /// await app.StartAsync(); /// /// // Open this in a browser to inspect the running application. -/// var loginUrl = await app.GetDashboardLoginUrlAsync(); +/// var dashboardUrl = await app.GetDashboardUrlAsync(); /// /// public sealed class DistributedApplicationTestingBuilderOptions @@ -38,12 +38,18 @@ public sealed class DistributedApplicationTestingBuilderOptions /// The default is . /// /// + /// /// When the dashboard runs, it listens on loopback endpoints using dynamically assigned ports, so concurrent /// test applications do not compete for a fixed port. Loopback still means every dashboard on the machine is /// reachable from it; what keeps applications out of each other's dashboards is that each one generates its own /// browser token. Use - /// to obtain an - /// authenticated URL for the running dashboard. + /// to obtain a browser-ready + /// URL for the running dashboard. + /// + /// + /// The dashboard process remains reachable when a debugger pauses the test process, but resource state changes, + /// logs, and commands flow through the paused process and do not update until execution resumes. + /// /// public bool EnableDashboard { get; set; } } diff --git a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs index 6e46dee8948..77556d413fb 100644 --- a/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs +++ b/src/Aspire.Hosting.Testing/Properties/Resources.Designer.cs @@ -79,38 +79,29 @@ internal static string DashboardDisabledExceptionMessage { } /// - /// Looks up a localized string similar to The dashboard login URL is not available because anonymous dashboard access is enabled.. + /// Looks up a localized string similar to The application must be started before retrieving the dashboard URL.. /// - internal static string DashboardLoginUrlAnonymousExceptionMessage { + internal static string DashboardUrlApplicationNotStartedExceptionMessage { get { - return ResourceManager.GetString("DashboardLoginUrlAnonymousExceptionMessage", resourceCulture); + return ResourceManager.GetString("DashboardUrlApplicationNotStartedExceptionMessage", resourceCulture); } } /// - /// Looks up a localized string similar to The application must be started before retrieving the dashboard login URL.. + /// Looks up a localized string similar to The dashboard URL is not available in publish mode.. /// - internal static string DashboardLoginUrlApplicationNotStartedExceptionMessage { + internal static string DashboardUrlPublishModeExceptionMessage { get { - return ResourceManager.GetString("DashboardLoginUrlApplicationNotStartedExceptionMessage", resourceCulture); + return ResourceManager.GetString("DashboardUrlPublishModeExceptionMessage", resourceCulture); } } /// - /// Looks up a localized string similar to The dashboard login URL is not available in publish mode.. + /// Looks up a localized string similar to The dashboard URL is not available.. /// - internal static string DashboardLoginUrlPublishModeExceptionMessage { + internal static string DashboardUrlUnavailableExceptionMessage { get { - return ResourceManager.GetString("DashboardLoginUrlPublishModeExceptionMessage", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The dashboard login URL is not available.. - /// - internal static string DashboardLoginUrlUnavailableExceptionMessage { - get { - return ResourceManager.GetString("DashboardLoginUrlUnavailableExceptionMessage", resourceCulture); + return ResourceManager.GetString("DashboardUrlUnavailableExceptionMessage", resourceCulture); } } diff --git a/src/Aspire.Hosting.Testing/Properties/Resources.resx b/src/Aspire.Hosting.Testing/Properties/Resources.resx index e675f85d789..cc5dcc3d91e 100644 --- a/src/Aspire.Hosting.Testing/Properties/Resources.resx +++ b/src/Aspire.Hosting.Testing/Properties/Resources.resx @@ -123,17 +123,14 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. + + The application must be started before retrieving the dashboard URL. - - The application must be started before retrieving the dashboard login URL. + + The dashboard URL is not available in publish mode. - - The dashboard login URL is not available in publish mode. - - - The dashboard login URL is not available. + + The dashboard URL is not available. Dashboard testing is not supported in publish mode. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf index 6f912830b86..7bc577a4364 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.cs.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf index a58967c1e45..86d7f8093d8 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.de.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf index 5ffc8bb5672..063c8087209 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.es.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf index a588096c6f6..888554c6c5c 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.fr.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf index e4f07a2ca74..9a04bc110fe 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.it.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf index 1fdf9f9c720..9823cb7f2ba 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ja.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf index 86815c0d358..fa3e4495858 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ko.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf index 8aee15257c3..5580e892519 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pl.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf index 4c55116c847..d51ea9f9546 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.pt-BR.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf index dbd9a9263e6..0dda672f576 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.ru.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf index efa071ff0d7..88c297690f7 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.tr.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf index 4f279b0e61b..2929dbe5bcd 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hans.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf index 87946aba067..646e0225a6d 100644 --- a/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf +++ b/src/Aspire.Hosting.Testing/Properties/xlf/Resources.zh-Hant.xlf @@ -12,29 +12,24 @@ The dashboard is not enabled for this application. - - The dashboard login URL is not available because anonymous dashboard access is enabled. - The dashboard login URL is not available because anonymous dashboard access is enabled. - - - - The application must be started before retrieving the dashboard login URL. - The application must be started before retrieving the dashboard login URL. + + Dashboard testing is not supported in publish mode. + Dashboard testing is not supported in publish mode. - - The dashboard login URL is not available in publish mode. - The dashboard login URL is not available in publish mode. + + The application must be started before retrieving the dashboard URL. + The application must be started before retrieving the dashboard URL. - - The dashboard login URL is not available. - The dashboard login URL is not available. + + The dashboard URL is not available in publish mode. + The dashboard URL is not available in publish mode. - - Dashboard testing is not supported in publish mode. - Dashboard testing is not supported in publish mode. + + The dashboard URL is not available. + The dashboard URL is not available. diff --git a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs index 953260572bb..0473d0fdd19 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardOptions.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardOptions.cs @@ -25,7 +25,7 @@ internal class DashboardOptions /// /// Set by Aspire.Hosting.Testing when it runs a dashboard for a test application. That dashboard's /// browser token is generated per application and handed to the test through - /// GetDashboardLoginUrlAsync, so writing it to the AppHost logger only publishes a live credential + /// GetDashboardUrlAsync, so writing it to the AppHost logger only publishes a live credential /// into test and CI output. The dashboard and OTLP endpoint lines are still written. /// public bool SuppressLoginUrlInStartupSummary { get; set; } diff --git a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj index 616c2ca5ad3..f63cad516b5 100644 --- a/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj +++ b/tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj @@ -6,6 +6,10 @@ enable false + true + true + true + false false @@ -47,4 +51,6 @@ + + diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs similarity index 52% rename from tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs rename to tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs index f1e713ef816..add0630c169 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardLoginUrlTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs @@ -3,27 +3,29 @@ using System.Net; using Aspire.TestUtilities; +using Aspire.Templates.Tests; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; +using Microsoft.Playwright; using Xunit; using TestingResources = Aspire.Hosting.Testing.Properties.Resources; namespace Aspire.Hosting.Testing.Tests; -public class DashboardLoginUrlTests +public class DashboardUrlTests { [Fact] [RequiresFeature(TestFeature.ContainerRuntime)] - public async Task GetDashboardLoginUrlAsyncAuthenticatesDashboardBrowser() + public async Task GetDashboardUrlAsyncAuthenticatesDashboardBrowser() { await using var builder = await CreateDashboardBuilderAsync(); await using var app = await builder.BuildAsync(); await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var dashboardUri = await app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); Assert.True(dashboardUri.IsAbsoluteUri); Assert.Equal(Uri.UriSchemeHttp, dashboardUri.Scheme); @@ -56,6 +58,118 @@ public async Task GetDashboardLoginUrlAsyncAuthenticatesDashboardBrowser() Assert.Equal(HttpStatusCode.OK, protectedResponse.StatusCode); } + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + [RequiresFeature(TestFeature.ContainerRuntime | TestFeature.Playwright)] + public async Task DashboardDisplaysResourceUpdatesWhileAppHostIsPausedAtBreakpoint() + { + const string resourceName = "dashboard-test-resource"; + const string initialState = "Dashboard test initial"; + const string updatedState = "Dashboard test updated"; + + using var breakpoint = TestingAppHostEntryPointProbe.CreateBreakpoint(); + await using var builder = await CreateDashboardBuilderAsync($"--entry-point-breakpoint-probe={breakpoint.Id}"); + builder.AddExternalService(resourceName, "https://example.com/"); + await using var app = await builder.BuildAsync(); + + try + { + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var cancellationToken = cancellationTokenSource.Token; + + // The AppHost signals this immediately after app.StartAsync() and then waits for Continue(). This models a + // debugger stopped on the next AppHost statement while hosted services, child resources, and the dashboard run. + await breakpoint.Reached.WaitAsync(cancellationToken); + + var runningResourceEvent = await app.ResourceNotifications.WaitForResourceAsync( + resourceName, + resourceEvent => resourceEvent.Snapshot.State?.Text == KnownResourceStates.Running, + cancellationToken); + + await app.ResourceNotifications.PublishUpdateAsync( + runningResourceEvent.Resource, + snapshot => snapshot with { State = initialState }); + + // GetDashboardUrlAsync is a dashboard-health barrier, not a resource-propagation barrier. The resource service + // caches notifications for dashboard clients, so a browser that connects now receives this state as initial data. + var dashboardUri = await app.GetDashboardUrlAsync(cancellationToken); + + PlaywrightProvider.DetectAndSetInstalledPlaywrightDependenciesPath(); + Assertions.SetDefaultExpectTimeout(TestConstants.LongTimeoutDuration); + var browser = await PlaywrightProvider.CreateBrowserAsync(); + try + { + await using var context = await browser.NewContextAsync(); + var page = await context.NewPageAsync(); + await page.GotoAsync(dashboardUri.AbsoluteUri); + + var resourceRow = page.Locator("tr.resource-row").Filter(new() { HasText = resourceName }); + var stateCell = resourceRow.Locator(".state-column-cell"); + + await Assertions.Expect(resourceRow).ToBeVisibleAsync(); + await Assertions.Expect(stateCell).ToContainTextAsync(initialState); + + var updatedResourceTask = app.ResourceNotifications.WaitForResourceAsync( + resourceName, + resourceEvent => resourceEvent.Snapshot.State?.Text == updatedState, + cancellationToken); + + await app.ResourceNotifications.PublishUpdateAsync( + runningResourceEvent.Resource, + snapshot => snapshot with { State = updatedState }); + await updatedResourceTask; + + // PublishUpdateAsync updates the application notification stream while the AppHost breakpoint remains held. + // Dashboard consumption and browser rendering are asynchronous, so wait separately for the rendered change. + await Assertions.Expect(stateCell).ToContainTextAsync(updatedState); + } + finally + { + await browser.CloseAsync(); + } + } + finally + { + // Release AppHost execution before the application is disposed; otherwise its entry point cannot finish cleanup. + breakpoint.Continue(); + } + } + + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncEncodesCustomBrowserToken() + { + const string BrowserToken = "browser&token#with+reserved"; + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["AppHost:BrowserToken"] = BrowserToken; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.Equal($"?t={Uri.EscapeDataString(BrowserToken)}", dashboardUri.Query); + + using var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true + }; + using var httpClient = new HttpClient(handler) + { + Timeout = TestConstants.LongTimeoutTimeSpan + }; + using var loginResponse = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + + Assert.Equal(HttpStatusCode.Redirect, loginResponse.StatusCode); + Assert.Equal("/", loginResponse.Headers.Location?.OriginalString); + Assert.Single( + loginResponse.Headers.GetValues("Set-Cookie"), + cookie => cookie.StartsWith(".Aspire.Dashboard.Auth", StringComparison.Ordinal)); + } + [Fact] [RequiresFeature(TestFeature.ContainerRuntime)] public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() @@ -68,8 +182,8 @@ public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() await Task.WhenAll(firstApp.StartAsync(), secondApp.StartAsync()).WaitAsync(TestConstants.LongTimeoutTimeSpan); using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var firstUrl = await firstApp.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); - var secondUrl = await secondApp.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + var firstUrl = await firstApp.GetDashboardUrlAsync(cancellationTokenSource.Token); + var secondUrl = await secondApp.GetDashboardUrlAsync(cancellationTokenSource.Token); var firstBaseUrl = firstUrl.GetLeftPart(UriPartial.Authority); Uri[] invalidLoginUrls = [ @@ -102,60 +216,69 @@ public async Task DashboardRejectsWrongCrossApplicationAndBogusLoginTokens() [Fact] [RequiresFeature(TestFeature.ContainerRuntime)] - public async Task GetDashboardLoginUrlAsyncThrowsWhenDashboardIsDisabled() + public async Task GetDashboardUrlAsyncThrowsWhenDashboardIsDisabled() { var builder = DistributedApplicationTestingBuilder.Create(); await using var app = await builder.BuildAsync(); await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); var exception = await Assert.ThrowsAsync( - () => app.GetDashboardLoginUrlAsync(default)); + () => app.GetDashboardUrlAsync(default)); Assert.Equal(TestingResources.DashboardDisabledExceptionMessage, exception.Message); } [Fact] [RequiresFeature(TestFeature.ContainerRuntime)] - public async Task GetDashboardLoginUrlAsyncThrowsWhenDashboardAllowsAnonymousAccess() + public async Task GetDashboardUrlAsyncReturnsBaseUrlWhenDashboardAllowsAnonymousAccess() { await using var builder = await CreateDashboardBuilderAsync(); builder.Configuration["AppHost:BrowserToken"] = string.Empty; await using var app = await builder.BuildAsync(); await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); - var exception = await Assert.ThrowsAsync( - () => app.GetDashboardLoginUrlAsync(default)); + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.True(dashboardUri.IsAbsoluteUri); + Assert.Equal(Uri.UriSchemeHttp, dashboardUri.Scheme); + Assert.True(dashboardUri.IsLoopback); + Assert.InRange(dashboardUri.Port, 1, 65535); + Assert.Equal("/", dashboardUri.AbsolutePath); + Assert.Equal(string.Empty, dashboardUri.Query); - Assert.Equal(TestingResources.DashboardLoginUrlAnonymousExceptionMessage, exception.Message); + using var httpClient = new HttpClient { Timeout = TestConstants.LongTimeoutTimeSpan }; + using var response = await httpClient.GetAsync(dashboardUri, cancellationTokenSource.Token); + response.EnsureSuccessStatusCode(); } [Fact] - public async Task GetDashboardLoginUrlAsyncThrowsBeforeApplicationStarts() + public async Task GetDashboardUrlAsyncThrowsBeforeApplicationStarts() { await using var builder = await CreateDashboardBuilderAsync(); await using var app = await builder.BuildAsync(); var exception = await Assert.ThrowsAsync( - () => app.GetDashboardLoginUrlAsync(default)); + () => app.GetDashboardUrlAsync(default)); - Assert.Equal(TestingResources.DashboardLoginUrlApplicationNotStartedExceptionMessage, exception.Message); + Assert.Equal(TestingResources.DashboardUrlApplicationNotStartedExceptionMessage, exception.Message); } [Fact] - public async Task GetDashboardLoginUrlAsyncThrowsInPublishMode() + public async Task GetDashboardUrlAsyncThrowsInPublishMode() { var builder = DistributedApplicationTestingBuilder.Create(["--publisher", "manifest"]); await using var app = await builder.BuildAsync(); var exception = await Assert.ThrowsAsync( - () => app.GetDashboardLoginUrlAsync(default)); + () => app.GetDashboardUrlAsync(default)); - Assert.Equal(TestingResources.DashboardLoginUrlPublishModeExceptionMessage, exception.Message); + Assert.Equal(TestingResources.DashboardUrlPublishModeExceptionMessage, exception.Message); } [Fact] [RequiresFeature(TestFeature.ContainerRuntime)] - public async Task GetDashboardLoginUrlAsyncPreservesTerminalDashboardFailure() + public async Task GetDashboardUrlAsyncPreservesTerminalDashboardFailure() { var missingDashboardPath = Path.Combine( AppContext.BaseDirectory, @@ -169,7 +292,7 @@ public async Task GetDashboardLoginUrlAsyncPreservesTerminalDashboardFailure() using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); var exception = await Assert.ThrowsAsync( - () => app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token)); + () => app.GetDashboardUrlAsync(cancellationTokenSource.Token)); Assert.Contains(KnownResourceNames.AspireDashboard, exception.Message, StringComparison.Ordinal); } @@ -186,7 +309,7 @@ public async Task DashboardStartupSummaryDoesNotWriteTheBrowserTokenToAppHostLog await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); - var dashboardUri = await app.GetDashboardLoginUrlAsync(cancellationTokenSource.Token); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); var token = dashboardUri.Query["?t=".Length..]; Assert.NotEmpty(token); @@ -216,11 +339,11 @@ private static string GetLogText(FakeLogRecord record) (accumulated, pair) => accumulated + " " + pair.Value); } - private static Task CreateDashboardBuilderAsync() + private static Task CreateDashboardBuilderAsync(params string[] args) { return DistributedApplicationTestingBuilder.CreateAsync( CreateDashboardOptions(), - []); + args); } private static DistributedApplicationTestingBuilderOptions CreateDashboardOptions() diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs index 68a0e735f13..f76aefae555 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/Program.cs @@ -91,6 +91,10 @@ await app.StartAsync(); +// Tests use this wait to model a debugger stopped on the next AppHost statement. The application has already +// started, so its hosted services, resources, and dashboard continue running while top-level execution is paused. +await TestingAppHostEntryPointProbe.WaitAtBreakpointAsync(args); + if (args.Contains("--wait-for-healthy")) { // Wait indefinitely until redis becomes healthy. diff --git a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs index 04ffb19099e..d78baaefc7e 100644 --- a/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs +++ b/tests/TestingAppHost1/TestingAppHost1.AppHost/TestingAppHostEntryPointProbe.cs @@ -6,7 +6,9 @@ public static class TestingAppHostEntryPointProbe { private const string ArgumentPrefix = "--entry-point-exit-probe="; + private const string BreakpointArgumentPrefix = "--entry-point-breakpoint-probe="; private static readonly ConcurrentDictionary s_probes = new(); + private static readonly ConcurrentDictionary s_breakpoints = new(); public static Probe Create() { @@ -39,6 +41,47 @@ public static IDisposable Track(string[] args) return new ExitSignal(exited); } + public static BreakpointProbe CreateBreakpoint() + { + var id = Guid.NewGuid().ToString("N"); + var signal = new BreakpointSignal( + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + if (!s_breakpoints.TryAdd(id, signal)) + { + throw new InvalidOperationException($"Could not create entry-point breakpoint probe '{id}'."); + } + + return new BreakpointProbe(id, signal.Reached, signal.Continue); + } + + public static async Task WaitAtBreakpointAsync(string[] args) + { + // The test passes the probe as: + // --entry-point-breakpoint-probe=<32-character GUID> + var argument = args.FirstOrDefault(arg => arg.StartsWith(BreakpointArgumentPrefix, StringComparison.Ordinal)); + if (argument is null) + { + return; + } + + var id = argument[BreakpointArgumentPrefix.Length..]; + if (!s_breakpoints.TryGetValue(id, out var signal)) + { + throw new InvalidOperationException($"Entry-point breakpoint probe '{id}' was not registered."); + } + + signal.Reached.TrySetResult(); + try + { + await signal.Continue.Task.ConfigureAwait(false); + } + finally + { + s_breakpoints.TryRemove(id, out _); + } + } + public sealed class Probe(string id, Task exited) : IDisposable { public string Id { get; } = id; @@ -51,6 +94,36 @@ public void Dispose() } } + public sealed class BreakpointProbe : IDisposable + { + private readonly TaskCompletionSource _continue; + + internal BreakpointProbe(string id, TaskCompletionSource reached, TaskCompletionSource @continue) + { + Id = id; + _continue = @continue; + Reached = reached.Task; + } + + public string Id { get; } + + public Task Reached { get; } + + public void Continue() + { + _continue.TrySetResult(); + } + + public void Dispose() + { + Continue(); + if (Reached.IsCompleted) + { + s_breakpoints.TryRemove(Id, out _); + } + } + } + private sealed class ExitSignal(TaskCompletionSource exited) : IDisposable { public void Dispose() @@ -59,6 +132,8 @@ public void Dispose() } } + private sealed record BreakpointSignal(TaskCompletionSource Reached, TaskCompletionSource Continue); + private sealed class EmptyDisposable : IDisposable { public static EmptyDisposable Instance { get; } = new(); From 1e32ada6798fad8e809c85e942043680685e3514 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 17 Aug 2026 20:45:11 -0400 Subject: [PATCH 15/21] Fix dashboard testing target host URL Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 522a7395-2ea1-4d1a-91b3-40050853b9f4 --- ...utedApplicationHostingTestingExtensions.cs | 31 +++++++++++++++++-- .../DashboardUrlTests.cs | 19 ++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs index 680a31fa69e..800a15f7a65 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using System.Reflection; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -14,6 +15,10 @@ namespace Aspire.Hosting.Testing; public static class DistributedApplicationHostingTestingExtensions { private const string DashboardResourceName = "aspire-dashboard"; + // Aspire.Hosting owns the shared target-host resolver, but it is internal to that assembly. + // Cache a delegate to the shared implementation so testing follows the same localhost-TLD + // resolution path without copying that logic into Aspire.Hosting.Testing. + private static readonly Func> s_getUrlWithTargetHostAsync = CreateGetUrlWithTargetHostAsync(); /// /// Gets the URL for the running Aspire dashboard. @@ -103,10 +108,17 @@ await app.ResourceNotifications.WaitForResourceHealthyAsync( throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); } + var dashboardUrl = await s_getUrlWithTargetHostAsync(dashboardEndpoint, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(dashboardUrl)) + { + throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); + } + var browserToken = app.Services.GetRequiredService()["AppHost:BrowserToken"]; - var dashboardUrl = string.IsNullOrEmpty(browserToken) - ? dashboardEndpoint.Url - : $"{dashboardEndpoint.Url.TrimEnd('/')}/login?t={Uri.EscapeDataString(browserToken)}"; + if (!string.IsNullOrEmpty(browserToken)) + { + dashboardUrl = $"{dashboardUrl.TrimEnd('/')}/login?t={Uri.EscapeDataString(browserToken)}"; + } if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri)) { @@ -226,6 +238,19 @@ public static Uri GetEndpointForNetwork(this DistributedApplication app, string return new(GetEndpointUriStringCore(app, resourceName, endpointName, networkIdentifier)); } + private static Func> CreateGetUrlWithTargetHostAsync() + { + var method = typeof(EndpointHostHelpers).GetMethod( + "GetUrlWithTargetHostAsync", + BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + [typeof(EndpointReference), typeof(CancellationToken)], + modifiers: null) + ?? throw new MissingMethodException(typeof(EndpointHostHelpers).FullName, "GetUrlWithTargetHostAsync"); + + return method.CreateDelegate>>(); + } + /// /// Gets the endpoint for the specified resource in the specified network context. /// diff --git a/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs index add0630c169..4793a87831b 100644 --- a/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs +++ b/tests/Aspire.Hosting.Testing.Tests/DashboardUrlTests.cs @@ -58,6 +58,25 @@ public async Task GetDashboardUrlAsyncAuthenticatesDashboardBrowser() Assert.Equal(HttpStatusCode.OK, protectedResponse.StatusCode); } + [Fact] + [RequiresFeature(TestFeature.ContainerRuntime)] + public async Task GetDashboardUrlAsyncUsesConfiguredLocalhostTld() + { + const string DashboardHost = "dashboard.testing.localhost"; + + await using var builder = await CreateDashboardBuilderAsync(); + builder.Configuration["ASPNETCORE_URLS"] = $"http://{DashboardHost}:0"; + await using var app = await builder.BuildAsync(); + await app.StartAsync().WaitAsync(TestConstants.LongTimeoutTimeSpan); + + using var cancellationTokenSource = new CancellationTokenSource(TestConstants.LongTimeoutTimeSpan); + var dashboardUri = await app.GetDashboardUrlAsync(cancellationTokenSource.Token); + + Assert.Equal(DashboardHost, dashboardUri.Host); + Assert.Equal("/login", dashboardUri.AbsolutePath); + Assert.StartsWith("?t=", dashboardUri.Query, StringComparison.Ordinal); + } + [Fact] [OuterloopTest("Resource-intensive Playwright browser test")] [RequiresFeature(TestFeature.ContainerRuntime | TestFeature.Playwright)] From 0d37444718dbaeee29b09f723613414fd5efb767 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 17 Aug 2026 21:12:03 -0400 Subject: [PATCH 16/21] Use direct dashboard endpoint host resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 522a7395-2ea1-4d1a-91b3-40050853b9f4 --- .../Aspire.Hosting.Testing.csproj | 8 -------- ...utedApplicationHostingTestingExtensions.cs | 20 +------------------ src/Aspire.Hosting/Aspire.Hosting.csproj | 1 + 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj index 49a983d2f81..ac1023a4328 100644 --- a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj +++ b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj @@ -11,14 +11,6 @@ false - - - - - - - - diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs index 800a15f7a65..ec794134ac1 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationHostingTestingExtensions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using System.Reflection; using Aspire.Hosting.ApplicationModel; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -15,10 +14,6 @@ namespace Aspire.Hosting.Testing; public static class DistributedApplicationHostingTestingExtensions { private const string DashboardResourceName = "aspire-dashboard"; - // Aspire.Hosting owns the shared target-host resolver, but it is internal to that assembly. - // Cache a delegate to the shared implementation so testing follows the same localhost-TLD - // resolution path without copying that logic into Aspire.Hosting.Testing. - private static readonly Func> s_getUrlWithTargetHostAsync = CreateGetUrlWithTargetHostAsync(); /// /// Gets the URL for the running Aspire dashboard. @@ -108,7 +103,7 @@ await app.ResourceNotifications.WaitForResourceHealthyAsync( throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); } - var dashboardUrl = await s_getUrlWithTargetHostAsync(dashboardEndpoint, cancellationToken).ConfigureAwait(false); + var dashboardUrl = await EndpointHostHelpers.GetUrlWithTargetHostAsync(dashboardEndpoint, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(dashboardUrl)) { throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage); @@ -238,19 +233,6 @@ public static Uri GetEndpointForNetwork(this DistributedApplication app, string return new(GetEndpointUriStringCore(app, resourceName, endpointName, networkIdentifier)); } - private static Func> CreateGetUrlWithTargetHostAsync() - { - var method = typeof(EndpointHostHelpers).GetMethod( - "GetUrlWithTargetHostAsync", - BindingFlags.NonPublic | BindingFlags.Static, - binder: null, - [typeof(EndpointReference), typeof(CancellationToken)], - modifiers: null) - ?? throw new MissingMethodException(typeof(EndpointHostHelpers).FullName, "GetUrlWithTargetHostAsync"); - - return method.CreateDelegate>>(); - } - /// /// Gets the endpoint for the specified resource in the specified network context. /// diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index afce9866480..e9b34bf16d3 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -138,6 +138,7 @@ + From 14ad8e365233045ebb92faec3b4a77510ecdd925 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 18 Aug 2026 00:47:44 -0400 Subject: [PATCH 17/21] Drop the product-to-product IVT and promote the target-host helper Reaching EndpointHostHelpers.GetUrlWithTargetHostAsync from Aspire.Hosting.Testing needed access that the package did not have. The first attempt granted Aspire.Hosting.Testing an InternalsVisibleTo in Aspire.Hosting.csproj, which is the exact pattern #18918 removed from Aspire.Hosting.Dotnet and 453da9c5 removed from Aspire.Hosting.Rust: two independently restorable packages with a default >= dependency range, so a version mismatch fails at runtime rather than at compile time, and none of the coupling is visible to API review. The grant also forced removing five $(SharedDir) Compile items from Aspire.Hosting.Testing, because source-shared internals and IVT-exposed internals are the same type twice (CS0436) - churn in the opposite direction from what the Rust change established. Every dependency the helper actually needs is already public: EndpointReference.EndpointAnnotation, EndpointReference.GetValueAsync, EndpointAnnotation.TargetHost, and EndpointHostHelpers.IsLocalhostTld. The internal modifier on this one method was incidental, and the class around it is already public with eight documented public methods. Make the method public, drop the IVT, and restore the shared Compile items. Aspire.Hosting.csproj now has no diff at all against the PR base. Cover the substitution rule in EndpointHostHelpersTests, which is deterministic and needs neither a container runtime nor a real socket, unlike the GetDashboardUrlAsync integration test that pins the call site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 522a7395-2ea1-4d1a-91b3-40050853b9f4 --- .../Aspire.Hosting.Testing.csproj | 8 +++++ .../ApplicationModel/EndpointHostHelpers.cs | 2 +- src/Aspire.Hosting/Aspire.Hosting.csproj | 1 - .../EndpointHostHelpersTests.cs | 31 +++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj index ac1023a4328..49a983d2f81 100644 --- a/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj +++ b/src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj @@ -11,6 +11,14 @@ false + + + + + + + + diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs index 9a955056b39..88e94a7df17 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs @@ -120,7 +120,7 @@ public static bool IsLocalhostOrLocalhostTld([NotNullWhen(true)] Uri? uri) /// The endpoint reference. /// A cancellation token. /// The URL with the appropriate hostname. - internal static async ValueTask GetUrlWithTargetHostAsync(EndpointReference endpoint, CancellationToken cancellationToken = default) + public static async ValueTask GetUrlWithTargetHostAsync(EndpointReference endpoint, CancellationToken cancellationToken = default) { var allocatedUrl = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index e9b34bf16d3..afce9866480 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -138,7 +138,6 @@ - diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs index 44f16084281..7570be5ef24 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs @@ -1,6 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Utils; +using Microsoft.AspNetCore.InternalTesting; + namespace Aspire.Hosting.Tests.ApplicationModel; [Trait("Partition", "4")] @@ -202,4 +205,32 @@ public void IsLocalhostOrLocalhostTld_VariousUriInputs_ReturnsExpectedResult(str // Assert Assert.Equal(expected, result); } + + [Theory] + [InlineData("dashboard.testing.localhost", "http://dashboard.testing.localhost:17454")] + [InlineData("app.dev.localhost", "http://app.dev.localhost:17454")] + [InlineData("localhost", "http://localhost:17454")] + [InlineData("example.com", "http://localhost:17454")] + public async Task GetUrlWithTargetHostAsync_UsesTargetHostOnlyForLocalhostTld(string targetHost, string expectedUrl) + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var container = builder.AddContainer("container1", "image") + .WithHttpEndpoint(name: "primary", targetPort: 10005) + .WithEndpoint("primary", ep => + { + ep.TargetHost = targetHost; + + // DCP allocates "localhost" even when a localhost TLD is configured, because + // "localhost" is the address the service actually binds to. The TLD hostname + // only ever exists in the URL presented back to the user. + ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 17454); + }); + + var url = await EndpointHostHelpers.GetUrlWithTargetHostAsync(container.GetEndpoint("primary")) + .AsTask() + .DefaultTimeout(); + + Assert.Equal(expectedUrl, url); + } } From 07ab893355fce18ac520bb20e05f6a393a54dc3f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 18 Aug 2026 04:15:21 -0400 Subject: [PATCH 18/21] Substitute the localhost TLD only for loopback addresses Making GetUrlWithTargetHostAsync public widened its contract to every EndpointReference, including one carrying a non-local ContextNetworkID. EndpointReference resolves those against the container network, so the same endpoint yields container1.dev.internal:10005 rather than a loopback address - and the method then replaced that host with the .localhost target host. A *.localhost name always resolves to the caller's own loopback, so the rewritten URL pointed a container at itself. Substitute only when the resolved address is itself loopback, which is exactly the case the method documents: DCP allocates "localhost" because that is what the service binds to, and the TLD is the name the user expects to see. All three production callers - the dashboard startup summary, the CLI backchannel API URL, and GetDashboardUrlAsync - resolve host-facing loopback endpoints, so their behavior is unchanged. Verified by a test that pins the container-network address; it reproduces the rewrite before the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 522a7395-2ea1-4d1a-91b3-40050853b9f4 --- .../ApplicationModel/EndpointHostHelpers.cs | 7 ++++- .../EndpointHostHelpersTests.cs | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs index 88e94a7df17..63b1f29ce98 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs @@ -132,8 +132,13 @@ public static bool IsLocalhostOrLocalhostTld([NotNullWhen(true)] Uri? uri) // If the configured TargetHost is a localhost TLD (e.g., aspire-dashboard.dev.localhost), // we need to use that instead of the allocated address (localhost) since the TLD hostname // is what the user expects to see and use in the browser. + // + // Only do this when the resolved address is itself loopback. The same endpoint resolves to a + // routable address (e.g. container1.dev.internal) when the reference carries a non-local + // ContextNetworkID, and a *.localhost name always resolves to the caller's own loopback, so + // substituting there would point a container at itself. var targetHost = endpoint.EndpointAnnotation.TargetHost; - if (IsLocalhostTld(targetHost) && Uri.TryCreate(allocatedUrl, UriKind.Absolute, out var uri)) + if (IsLocalhostTld(targetHost) && Uri.TryCreate(allocatedUrl, UriKind.Absolute, out var uri) && uri.IsLoopback) { return $"{uri.Scheme}://{targetHost}:{uri.Port}"; } diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs index 7570be5ef24..74495545714 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs @@ -233,4 +233,34 @@ public async Task GetUrlWithTargetHostAsync_UsesTargetHostOnlyForLocalhostTld(st Assert.Equal(expectedUrl, url); } + + [Fact] + public async Task GetUrlWithTargetHostAsync_KeepsAddressResolvedForANonLocalNetworkContext() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var container = builder.AddContainer("container1", "image") + .WithHttpEndpoint(name: "primary", targetPort: 10005) + .WithEndpoint("primary", ep => + { + ep.TargetHost = "app.dev.localhost"; + ep.AllocatedEndpoint = new AllocatedEndpoint(ep, "localhost", 17454); + ep.AllAllocatedEndpoints.AddOrUpdateAllocatedEndpoint( + KnownNetworkIdentifiers.DefaultAspireContainerNetwork, + new AllocatedEndpoint(ep, "container1.dev.internal", 10005, EndpointBindingMode.SingleAddress, networkId: KnownNetworkIdentifiers.DefaultAspireContainerNetwork)); + }); + + var containerNetworkEndpoint = new EndpointReference( + container.Resource, + container.GetEndpoint("primary").EndpointAnnotation, + KnownNetworkIdentifiers.DefaultAspireContainerNetwork); + + var url = await EndpointHostHelpers.GetUrlWithTargetHostAsync(containerNetworkEndpoint) + .AsTask() + .DefaultTimeout(); + + // A localhost TLD only ever resolves on the host loopback, so it must not be substituted into an + // address that was resolved for the container network - the container would be sent to its own loopback. + Assert.Equal("http://container1.dev.internal:10005", url); + } } From 10bbdf1c72d7ba45a8481b3bc1eb5c77055a3b3a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 18 Aug 2026 04:46:50 -0400 Subject: [PATCH 19/21] Validate the endpoint argument on the public target-host helper GetUrlWithTargetHostAsync became public in an earlier commit on this branch, so `endpoint` is now an external API input. It was dereferenced without validation, which surfaces a NullReferenceException instead of the repository-standard ArgumentNullException when a null reaches it from a language without nullable reference type enforcement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 522a7395-2ea1-4d1a-91b3-40050853b9f4 --- src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs | 3 +++ .../ApplicationModel/EndpointHostHelpersTests.cs | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs index 63b1f29ce98..b211ad247a0 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs @@ -120,8 +120,11 @@ public static bool IsLocalhostOrLocalhostTld([NotNullWhen(true)] Uri? uri) /// The endpoint reference. /// A cancellation token. /// The URL with the appropriate hostname. + /// Thrown when is null. public static async ValueTask GetUrlWithTargetHostAsync(EndpointReference endpoint, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(endpoint); + var allocatedUrl = await endpoint.GetValueAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(allocatedUrl)) diff --git a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs index 74495545714..6c1683160c9 100644 --- a/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs +++ b/tests/Aspire.Hosting.Tests/ApplicationModel/EndpointHostHelpersTests.cs @@ -263,4 +263,11 @@ public async Task GetUrlWithTargetHostAsync_KeepsAddressResolvedForANonLocalNetw // address that was resolved for the container network - the container would be sent to its own loopback. Assert.Equal("http://container1.dev.internal:10005", url); } + + [Fact] + public async Task GetUrlWithTargetHostAsync_ThrowsForNullEndpoint() + { + await Assert.ThrowsAsync( + async () => await EndpointHostHelpers.GetUrlWithTargetHostAsync(null!)); + } } From 5ab19de638f4b11a8cc11813df0c964406610450 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 18 Aug 2026 10:39:24 -0400 Subject: [PATCH 20/21] Describe the endpoint URL resolution on the helper type summary EndpointHostHelpers is a shipped public type whose summary described it as validating localhost addresses. GetUrlWithTargetHostAsync became public on this branch, so the type now also resolves endpoint URLs against a configured target host and the summary no longer matched what the type exposes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 522a7395-2ea1-4d1a-91b3-40050853b9f4 --- src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs index b211ad247a0..1c6c50316d9 100644 --- a/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs +++ b/src/Aspire.Hosting/ApplicationModel/EndpointHostHelpers.cs @@ -6,7 +6,8 @@ namespace Aspire.Hosting.ApplicationModel; /// -/// Provides helper methods for validating localhost addresses. +/// Provides helper methods for validating localhost addresses and for resolving endpoint URLs +/// against a configured target host. /// public static class EndpointHostHelpers { From 6884e1376f92a0046e4eb17afa607be14eaa9f76 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 18 Aug 2026 16:23:47 -0400 Subject: [PATCH 21/21] Clarify dashboard testing isolation and wait behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributedApplicationTestingBuilderOptions.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs index 02b5f034ee9..49cc6cc7999 100644 --- a/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs +++ b/src/Aspire.Hosting.Testing/DistributedApplicationTestingBuilderOptions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.ApplicationModel; + namespace Aspire.Hosting.Testing; /// @@ -40,13 +42,19 @@ public sealed class DistributedApplicationTestingBuilderOptions /// /// /// When the dashboard runs, it listens on loopback endpoints using dynamically assigned ports, so concurrent - /// test applications do not compete for a fixed port. Loopback still means every dashboard on the machine is - /// reachable from it; what keeps applications out of each other's dashboards is that each one generates its own - /// browser token. Use + /// test applications do not compete for a fixed port. Each application generates its own browser token instead + /// of reusing an ambient dashboard credential. Dashboard instances in the same browser still share the dashboard + /// authentication cookie, so use separate browser contexts when tests require isolation between dashboards. Use /// to obtain a browser-ready /// URL for the running dashboard. /// /// + /// Enabling the dashboard sets to + /// after AppHost configuration. To use a different default + /// for WaitFor, configure through the returned builder + /// before building the application. + /// + /// /// The dashboard process remains reachable when a debugger pauses the test process, but resource state changes, /// logs, and commands flow through the paused process and do not update until execution resumes. ///