Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
58017af
Add dashboard support to testing builders
Aug 7, 2026
e07a461
Merge microsoft/main into adamint/feature-15575-testing-dashboard
Aug 7, 2026
8ef74cd
Finish testing dashboard support
Aug 7, 2026
51f8c6b
Address review findings for dashboard-in-tests support
Aug 7, 2026
8ae0f6d
Fix dashboard testing regressions found by CI
Aug 7, 2026
10b6337
Pin the AppHost browser token boundary with a test
Aug 7, 2026
d776ba6
Do not dispose an application a concurrent build still owns
Aug 7, 2026
b08bf91
Restore the browser token an AppHost cleared
Aug 7, 2026
9880d13
Merge remote-tracking branch 'microsoft/main' into adamint/feature-15…
Aug 7, 2026
b1a1fd4
Close a disposal race that could leave the application running
adamint Aug 9, 2026
0524c70
Restore resource-service authentication after AppHost configuration
adamint Aug 9, 2026
d608e0a
Keep the dashboard browser token out of AppHost logs in testing mode
adamint Aug 9, 2026
7942f43
Merge branch 'main' into adamint/feature-15575-testing-dashboard
adamint Aug 10, 2026
3bca434
Keep dashboard testing focused
Aug 10, 2026
4da93af
Merge upstream/main into adamint/feature-15575-testing-dashboard
Aug 14, 2026
d29d255
Address remaining dashboard testing review feedback
Aug 14, 2026
9e532fc
Suppress dashboard tokens in forwarded test logs
Aug 14, 2026
38ced12
Support running the dashboard from tests
adamint Aug 17, 2026
1e32ada
Fix dashboard testing target host URL
Aug 18, 2026
0d37444
Use direct dashboard endpoint host resolution
Aug 18, 2026
14ad8e3
Drop the product-to-product IVT and promote the target-host helper
Aug 18, 2026
07ab893
Substitute the localhost TLD only for loopback addresses
adamint Aug 18, 2026
10bbdf1
Validate the endpoint argument on the public target-host helper
adamint Aug 18, 2026
5ab19de
Describe the endpoint URL resolution on the helper type summary
adamint Aug 18, 2026
6884e13
Clarify dashboard testing isolation and wait behavior
adamint Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,10 @@ public DashboardWebApplication(
private void PrintSummary(ResolvedEndpointInfo? frontendEndpointInfo)
{
var options = _app.Services.GetRequiredService<IOptionsMonitor<DashboardOptions>>().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);
Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Hosting.Testing/Aspire.Hosting.Testing.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<Compile Include="$(SharedDir)LaunchProfiles\LaunchSettingsSerializerContext.cs" Link="LaunchSettingsSerializerContext.cs" />
<Compile Include="$(SharedDir)\KnownAspNetCoreConfigNames.cs" Link="KnownAspNetCoreConfigNames.cs" />
<Compile Include="$(SharedDir)\KnownConfigNames.cs" Link="KnownConfigNames.cs" />
<Compile Include="$(SharedDir)\TokenGenerator.cs" Link="TokenGenerator.cs" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

Expand All @@ -12,6 +13,129 @@ namespace Aspire.Hosting.Testing;
/// </summary>
public static class DistributedApplicationHostingTestingExtensions
{
private const string DashboardResourceName = "aspire-dashboard";

/// <summary>
/// Gets the URL for the running Aspire dashboard.
/// </summary>
/// <param name="app">The distributed application.</param>
/// <param name="cancellationToken">A token used to cancel the operation.</param>
/// <returns>
/// An absolute <see cref="Uri"/> that can be opened in a browser. The URL authenticates the browser when
/// authentication is enabled and otherwise points directly to the dashboard.
/// </returns>
/// <remarks>
/// <para>
/// Enable dashboard support with <see cref="DistributedApplicationTestingBuilderOptions.EnableDashboard"/> when creating
/// the testing builder.
/// </para>
/// <para>
/// This method does not start the distributed application. Call <see cref="DistributedApplication.StartAsync(CancellationToken)"/>
/// before requesting the dashboard URL.
/// </para>
/// <para>
/// This method waits for the dashboard resource to become healthy. Pass a cancellation token when the wait must
/// be bounded. When authentication is enabled, the returned URI contains an authentication credential and should
/// be treated as sensitive.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="app"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when the application is in publish mode, the dashboard is disabled, the application has not started,
/// or a dashboard URL is unavailable.
/// </exception>
/// <exception cref="DistributedApplicationException">Thrown when the dashboard reaches a terminal failure state.</exception>
/// <exception cref="OperationCanceledException">Thrown when <paramref name="cancellationToken"/> is canceled.</exception>
/// <exception cref="ObjectDisposedException">Thrown when the distributed application has been disposed.</exception>
/// <example>
/// <code lang="csharp">
/// var options = new DistributedApplicationTestingBuilderOptions
/// {
/// EnableDashboard = true
/// };
///
/// var builder = await DistributedApplicationTestingBuilder.CreateAsync&lt;Projects.MyAppHost_AppHost&gt;(options, []);
/// await using var app = await builder.BuildAsync();
/// await app.StartAsync();
///
/// var dashboardUrl = await app.GetDashboardUrlAsync();
/// </code>
/// </example>
[AspireExportIgnore(Reason = "Use the exported getDashboardUrl overload without a cancellation token.")]
public static async Task<Uri> GetDashboardUrlAsync(
this DistributedApplication app,
CancellationToken cancellationToken = default)
Comment thread
adamint marked this conversation as resolved.
Resolved
{
ArgumentNullException.ThrowIfNull(app);

var executionContext = app.Services.GetRequiredService<DistributedApplicationExecutionContext>();
if (executionContext.IsPublishMode)
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlPublishModeExceptionMessage);
}

var applicationOptions = app.Services.GetRequiredService<DistributedApplicationOptions>();
if (applicationOptions.DisableDashboard)
{
throw new InvalidOperationException(Properties.Resources.DashboardDisabledExceptionMessage);
}

ThrowIfNotStarted(app, Properties.Resources.DashboardUrlApplicationNotStartedExceptionMessage);
cancellationToken.ThrowIfCancellationRequested();

await app.ResourceNotifications.WaitForResourceHealthyAsync(
DashboardResourceName,
WaitBehavior.StopOnResourceUnavailable,
cancellationToken).ConfigureAwait(false);

var applicationModel = app.Services.GetRequiredService<DistributedApplicationModel>();
if (!applicationModel.Resources.TryGetByName(DashboardResourceName, out var resource) ||
resource is not IResourceWithEndpoints dashboardResource)
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage);
}

var httpsEndpoint = dashboardResource.GetEndpoint("https");
var httpEndpoint = dashboardResource.GetEndpoint("http");
var dashboardEndpoint = httpsEndpoint.Exists ? httpsEndpoint : httpEndpoint;
if (!dashboardEndpoint.Exists)
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage);
}

var dashboardUrl = await EndpointHostHelpers.GetUrlWithTargetHostAsync(dashboardEndpoint, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(dashboardUrl))
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage);
}

var browserToken = app.Services.GetRequiredService<IConfiguration>()["AppHost:BrowserToken"];
if (!string.IsNullOrEmpty(browserToken))
{
dashboardUrl = $"{dashboardUrl.TrimEnd('/')}/login?t={Uri.EscapeDataString(browserToken)}";
}

if (!Uri.TryCreate(dashboardUrl, UriKind.Absolute, out var dashboardUri))
{
throw new InvalidOperationException(Properties.Resources.DashboardUrlUnavailableExceptionMessage);
}

return dashboardUri;
}

/// <summary>
/// Gets the URL for the running Aspire dashboard.
/// </summary>
/// <returns>
/// An absolute <see cref="Uri"/> that can be opened in a browser. When authentication is enabled, the URI
/// contains an authentication credential and should be treated as sensitive.
/// </returns>
[AspireExport("getDashboardUrl")]
internal static Task<Uri> GetDashboardUrlAsyncExport(this DistributedApplication app)
{
return app.GetDashboardUrlAsync(default);
}

/// <summary>
/// Creates an <see cref="HttpClient"/> configured to communicate with the specified resource.
/// </summary>
Expand Down Expand Up @@ -127,7 +251,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<DistributedApplicationModel>();

if (!applicationModel.Resources.TryGetByName(resourceName, out var resource))
Expand Down Expand Up @@ -166,12 +290,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<IHostApplicationLifetime>();
if (!lifetime.ApplicationStarted.IsCancellationRequested)
{
throw new InvalidOperationException(Properties.Resources.ApplicationNotStartedExceptionMessage);
throw new InvalidOperationException(exceptionMessage);
}
}

Expand Down
Loading
Loading