|
// 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 System.Text.Json;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Aspire.Hosting.Tests.Dashboard;
public class DashboardResourceTests(ITestOutputHelper testOutputHelper)
{
[Fact]
public async Task DashboardIsAutomaticallyAddedAsHiddenResource()
{
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
// Ensure any ambient configuration doesn't impact this test.
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["DOTNET_ASPIRE_SHOW_DASHBOARD_RESOURCES"] = null
});
var dashboardPath = Path.GetFullPath("dashboard");
builder.Services.Configure<DcpOptions>(o =>
{
o.DashboardPath = dashboardPath;
});
using var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var dashboard = Assert.Single(model.Resources.OfType<ExecutableResource>());
var initialSnapshot = Assert.Single(dashboard.Annotations.OfType<ResourceSnapshotAnnotation>());
Assert.NotNull(dashboard);
Assert.Equal("aspire-dashboard", dashboard.Name);
Assert.Equal(dashboardPath, dashboard.Command);
Assert.Equal("Hidden", initialSnapshot.InitialSnapshot.State);
}
[Fact]
public async Task DashboardIsAddedFirst()
{
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
builder.AddContainer("my-container", "my-image");
using var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
Assert.Collection(model.Resources,
r => Assert.Equal("aspire-dashboard", r.Name),
r => Assert.Equal("my-container", r.Name)
);
}
[Fact]
public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard()
{
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
builder.Services.AddSingleton<IDashboardEndpointProvider, MockDashboardEndpointProvider>();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ASPNETCORE_URLS"] = "http://localhost",
["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost"
});
var container = builder.AddContainer(KnownResourceNames.AspireDashboard, "my-image");
using var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var dashboard = Assert.Single(model.Resources);
Assert.Same(container.Resource, dashboard);
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
Assert.Collection(config,
e =>
{
Assert.Equal("ASPNETCORE_ENVIRONMENT", e.Key);
Assert.Equal("Production", e.Value);
},
e =>
{
Assert.Equal("ASPNETCORE_URLS", e.Key);
Assert.Equal("http://localhost", e.Value);
},
e =>
{
Assert.Equal("DOTNET_RESOURCE_SERVICE_ENDPOINT_URL", e.Key);
Assert.Equal("http://localhost:5000", e.Value);
},
e =>
{
Assert.Equal("DOTNET_DASHBOARD_OTLP_ENDPOINT_URL", e.Key);
Assert.Equal("http://localhost", e.Value);
},
e =>
{
Assert.Equal("DASHBOARD__FRONTEND__AUTHMODE", e.Key);
Assert.Equal("Unsecured", e.Value);
},
e =>
{
Assert.Equal("DASHBOARD__RESOURCESERVICECLIENT__AUTHMODE", e.Key);
Assert.Equal("Unsecured", e.Value);
},
e =>
{
Assert.Equal("DASHBOARD__OTLP__AUTHMODE", e.Key);
Assert.Equal("Unsecured", e.Value);
},
e =>
{
Assert.Equal("LOGGING__CONSOLE__FORMATTERNAME", e.Key);
Assert.Equal("json", e.Value);
}
);
}
[Fact]
public async Task DashboardWithDllPathLaunchesDotnet()
{
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
var dashboardPath = Path.GetFullPath("dashboard.dll");
builder.Services.Configure<DcpOptions>(o =>
{
o.DashboardPath = dashboardPath;
});
var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var dashboard = Assert.Single(model.Resources.OfType<ExecutableResource>());
var args = await ArgumentEvaluator.GetArgumentListAsync(dashboard).DefaultTimeout();
Assert.NotNull(dashboard);
Assert.Equal("aspire-dashboard", dashboard.Name);
Assert.Equal("dotnet", dashboard.Command);
Assert.Equal([dashboardPath], args);
}
[Fact]
public async Task DashboardAuthConfigured_EnvVarsPresent()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
builder.Services.AddSingleton<IDashboardEndpointProvider, MockDashboardEndpointProvider>();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ASPNETCORE_URLS"] = "http://localhost",
["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost",
["AppHost:BrowserToken"] = "TestBrowserToken!",
["AppHost:OtlpApiKey"] = "TestOtlpApiKey!"
});
using var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var dashboard = Assert.Single(model.Resources);
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
Assert.Equal("BrowserToken", config.Single(e => e.Key == DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName).Value);
Assert.Equal("TestBrowserToken!", config.Single(e => e.Key == DashboardConfigNames.DashboardFrontendBrowserTokenName.EnvVarName).Value);
Assert.Equal("ApiKey", config.Single(e => e.Key == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value);
Assert.Equal("TestOtlpApiKey!", config.Single(e => e.Key == DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.EnvVarName).Value);
}
[Fact]
public async Task DashboardAuthRemoved_EnvVarsUnsecured()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
builder.Services.AddSingleton<IDashboardEndpointProvider, MockDashboardEndpointProvider>();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ASPNETCORE_URLS"] = "http://localhost",
["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost"
});
using var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var dashboard = Assert.Single(model.Resources);
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
Assert.Equal("Unsecured", config.Single(e => e.Key == DashboardConfigNames.DashboardFrontendAuthModeName.EnvVarName).Value);
Assert.Equal("Unsecured", config.Single(e => e.Key == DashboardConfigNames.DashboardOtlpAuthModeName.EnvVarName).Value);
}
[Fact]
public async Task DashboardResourceServiceUriIsSet()
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
builder.Services.AddSingleton<IDashboardEndpointProvider, MockDashboardEndpointProvider>();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ASPNETCORE_URLS"] = "http://localhost",
["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost"
});
using var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var dashboard = Assert.Single(model.Resources);
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout();
Assert.Equal("http://localhost:5000", config.Single(e => e.Key == DashboardConfigNames.ResourceServiceUrlName.EnvVarName).Value);
}
[Theory]
[InlineData("*")]
[InlineData(null)]
public async Task DashboardResource_OtlpHttpEndpoint_CorsEnvVarSet(string? explicitCorsAllowedOrigins)
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
builder.AddContainer("my-container", "my-image").WithHttpEndpoint(port: 8080, targetPort: 58080);
builder.Services.AddSingleton<IDashboardEndpointProvider, MockDashboardEndpointProvider>();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ASPNETCORE_URLS"] = "http://localhost",
["DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL"] = "http://localhost",
["DOTNET_DASHBOARD_CORS_ALLOWED_ORIGINS"] = explicitCorsAllowedOrigins
});
using var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
// Hack in an AllocatedEndpoint. This is what is used to build the list of CORS endpoints.
var container = Assert.Single(model.Resources.Where(r => r.Name == "my-container"));
var endpointAnnotation = Assert.Single(container.Annotations.OfType<EndpointAnnotation>());
endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(endpointAnnotation, "localhost", 8081);
var dashboard = Assert.Single(model.Resources.Where(r => r.Name == "aspire-dashboard"));
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, app.Services).DefaultTimeout();
var expectedAllowedOrigins = !string.IsNullOrEmpty(explicitCorsAllowedOrigins) ? explicitCorsAllowedOrigins : "http://localhost:8081,http://localhost:58080";
Assert.Equal(expectedAllowedOrigins, config.Single(e => e.Key == DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.EnvVarName).Value);
Assert.Equal("*", config.Single(e => e.Key == DashboardConfigNames.DashboardOtlpCorsAllowedHeadersKeyName.EnvVarName).Value);
}
[Theory]
[InlineData("*")]
[InlineData(null)]
public async Task DashboardResource_OtlpGrpcEndpoint_CorsEnvVarNotSet(string? explicitCorsAllowedOrigins)
{
// Arrange
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
builder.AddContainer("my-container", "my-image").WithHttpEndpoint(port: 8080, targetPort: 58080);
builder.Services.AddSingleton<IDashboardEndpointProvider, MockDashboardEndpointProvider>();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ASPNETCORE_URLS"] = "http://localhost",
["DOTNET_DASHBOARD_OTLP_ENDPOINT_URL"] = "http://localhost",
["DOTNET_DASHBOARD_CORS_ALLOWED_ORIGINS"] = explicitCorsAllowedOrigins
});
using var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var dashboard = Assert.Single(model.Resources.Where(r => r.Name == "aspire-dashboard"));
var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(dashboard, DistributedApplicationOperation.Run, app.Services).DefaultTimeout();
Assert.DoesNotContain(config, e => e.Key == DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.EnvVarName);
Assert.DoesNotContain(config, e => e.Key == DashboardConfigNames.DashboardOtlpCorsAllowedHeadersKeyName.EnvVarName);
}
[Fact]
public async Task DashboardIsNotAddedInPublishMode()
{
using var builder = TestDistributedApplicationBuilder.Create(
options =>
{
options.DisableDashboard = false;
options.Args = ["--publisher", "manifest"];
},
testOutputHelper: testOutputHelper);
using var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
Assert.Empty(model.Resources);
}
[Fact]
public async Task DashboardIsNotAddedIfDisabled()
{
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = true,
testOutputHelper: testOutputHelper);
var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
Assert.Empty(model.Resources);
}
[Fact]
public void ContainerIsValidWithDashboardIsDisabled()
{
// Set the host environment to "Development" so that the container validates services.
using var builder = TestDistributedApplicationBuilder.Create(
options =>
{
options.DisableDashboard = true;
options.Args = ["--environment", "Development"];
},
testOutputHelper: testOutputHelper);
// Container validation logic runs when the service provider is built.
using var app = builder.Build();
}
[Theory]
[InlineData(LogLevel.Critical)]
[InlineData(LogLevel.Error)]
[InlineData(LogLevel.Warning)]
[InlineData(LogLevel.Information)]
[InlineData(LogLevel.Debug)]
[InlineData(LogLevel.Trace)]
public async Task DashboardLifecycleHookWatchesLogs(LogLevel logLevel)
{
using var builder = TestDistributedApplicationBuilder.Create(
options => options.DisableDashboard = false,
testOutputHelper: testOutputHelper);
var loggerProvider = new TestLoggerProvider();
builder.Services.AddLogging(b =>
{
b.AddProvider(loggerProvider);
b.AddFilter("Aspire.Hosting.Dashboard", logLevel);
});
var dashboardPath = Path.GetFullPath("dashboard");
builder.Services.Configure<DcpOptions>(o =>
{
o.DashboardPath = dashboardPath;
});
var app = builder.Build();
var resourceLoggerService = app.Services.GetRequiredService<ResourceLoggerService>();
var watchForLogSubs = Task.Run(async () =>
{
await foreach (var sub in resourceLoggerService.WatchAnySubscribersAsync())
{
if (sub.AnySubscribers)
{
break;
}
}
});
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var resourceNotificationService = app.Services.GetRequiredService<ResourceNotificationService>();
var dashboard = Assert.Single(model.Resources.OfType<ExecutableResource>());
Assert.NotNull(dashboard);
Assert.Equal("aspire-dashboard", dashboard.Name);
// Push a notification through to the dashboard resource.
await resourceNotificationService.PublishUpdateAsync(dashboard, "aspire-dashboard-0", s => s with { State = "Running" }).DefaultTimeout();
// Wait for logs to be subscribed to
await watchForLogSubs.DefaultTimeout();
// Push some logs through to the dashboard resource.
var logger = resourceLoggerService.GetLogger("aspire-dashboard-0");
// The logging watcher expects a JSON payload
var dashboardLogMessage = new DashboardLogMessage
{
Category = "Test",
LogLevel = logLevel,
Message = "Test dashboard message"
};
logger.Log(logLevel, 0, JsonSerializer.Serialize(dashboardLogMessage), null, (s, _) => s);
// Get the logger with the category we expect Aspire.Hosting.Dashboard.Test
var testLogger = loggerProvider.CreateLogger("Aspire.Hosting.Dashboard.Test") as TestLogger;
Assert.NotNull(testLogger);
// Get the first log message that was logged
var log = await testLogger.FirstLogTask.DefaultTimeout();
Assert.Equal("Test dashboard message", log.Message);
Assert.Equal(logLevel, log.LogLevel);
await app.DisposeAsync().AsTask().DefaultTimeout();
}
[Fact]
public async Task DashboardIsExcludedFromManifestInPublishModeEvenIfAddedExplicitly()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
builder.AddProject<DashboardProject>(KnownResourceNames.AspireDashboard);
var app = builder.Build();
await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout();
var model = app.Services.GetRequiredService<DistributedApplicationModel>();
var dashboard = Assert.Single(model.Resources.OfType<ProjectResource>());
Assert.NotNull(dashboard);
var annotation = Assert.Single(dashboard.Annotations.OfType<ManifestPublishingCallbackAnnotation>());
var manifest = await ManifestUtils.GetManifestOrNull(dashboard).DefaultTimeout();
Assert.Equal("aspire-dashboard", dashboard.Name);
Assert.Same(ManifestPublishingCallbackAnnotation.Ignore, annotation);
Assert.Null(manifest);
}
private sealed class DashboardProject : IProjectMetadata
{
public string ProjectPath => "dashboard.csproj";
public LaunchSettings LaunchSettings { get; } = new();
}
private sealed class TestLogger : ILogger
{
private readonly TaskCompletionSource<LogMessage> _tcs = new();
public Task<LogMessage> FirstLogTask => _tcs.Task;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
var message = new LogMessage
{
LogLevel = logLevel,
Message = formatter(state, exception)
};
_tcs.TrySetResult(message);
}
public sealed class LogMessage
{
public LogLevel LogLevel { get; set; }
public string Message { get; set; } = string.Empty;
}
}
private sealed class TestLoggerProvider : ILoggerProvider
{
private readonly ConcurrentDictionary<string, TestLogger> _loggers = new();
public ILogger CreateLogger(string categoryName)
{
return _loggers.GetOrAdd(categoryName, _ => new TestLogger());
}
public void Dispose() { }
}
private sealed class MockDashboardEndpointProvider : IDashboardEndpointProvider
{
public Task<string> GetResourceServiceUriAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult("http://localhost:5000");
}
}
}
|