From 21665baa36b82264c20f099c69b020a343b070ba Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 31 Oct 2025 09:25:27 +0800 Subject: [PATCH 1/4] Improve flaky dashboard integration test --- .../DashboardWebApplication.cs | 5 +++ .../Integration/ServerRetryHelper.cs | 7 +++- .../Integration/StartupTests.cs | 38 +++++++++++++------ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index e48efe90f10..e35864e20be 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -378,6 +378,11 @@ public DashboardWebApplication( // This isn't used by dotnet watch but still useful to have for debugging _logger.LogInformation("OTLP/HTTP listening on: {OtlpEndpointUri}", _otlpServiceHttpEndPointAccessor().GetResolvedAddress()); } + if (_mcpEndPointAccessor != null) + { + // This isn't used by dotnet watch but still useful to have for debugging + _logger.LogInformation("MCP listening on: {McpEndpointUri}", _mcpEndPointAccessor().GetResolvedAddress()); + } if (_dashboardOptionsMonitor.CurrentValue.Otlp.AuthMode == OtlpAuthMode.Unsecured) { diff --git a/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs b/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs index 1f246deb632..ed0e8782d30 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs @@ -39,7 +39,12 @@ public static async Task BindPortsWithRetry(Func, Task> retryFunc, ILo var port = GetAvailablePort(nextPortAttempt, logger); ports.Add(port); - nextPortAttempt = port + Random.Shared.Next(100); + nextPortAttempt = port + Random.Shared.Next(10, 100); + } + + if (ports.Count != ports.Distinct().Count()) + { + throw new InvalidOperationException($"Generated ports list contains duplicate numbers: {string.Join(", ", ports)}"); } try diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index 95e027f2640..14ecbe5a104 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.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.Collections.Concurrent; using System.Net; using System.Security.Cryptography.X509Certificates; using System.Text.Json.Nodes; @@ -729,31 +730,40 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() public async Task LogOutput_LocalhostAddress_LocalhostInLogOutput() { // Arrange - TestSink? testSink = null; + var testSink = new TestSink(); + var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(testOutputHelper, testSink); + DashboardWebApplication? app = null; int? frontendPort1 = null; int? frontendPort2 = null; - int? otlpPort = null; + int? otlpGrpcPort = null; + int? otlpHttpPort = null; try { await ServerRetryHelper.BindPortsWithRetry(async ports => { frontendPort1 = ports[0]; frontendPort2 = ports[1]; - otlpPort = ports[2]; + otlpGrpcPort = ports[2]; + otlpHttpPort = ports[3]; - testSink = new TestSink(); - app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, + // Reset sink writes. Required to clear out data from a previous failed retry. + var writes = (ConcurrentQueue)testSink.Writes; + writes.Clear(); + + app = IntegrationTestHelpers.CreateDashboardWebApplication(loggerFactory, additionalConfiguration: data => { data[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = $"https://localhost:{frontendPort1};http://localhost:{frontendPort2}"; - data[DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] = $"http://localhost:{otlpPort}"; - }, testSink: testSink); + data[DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] = $"http://localhost:{otlpGrpcPort}"; + data[DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey] = $"http://localhost:{otlpHttpPort}"; + data[DashboardConfigNames.DashboardMcpUrlName.ConfigKey] = "http://127.0.0.1:0"; // Test that a dynamic port has a set value in logs. + }); // Act await app.StartAsync().DefaultTimeout(); - }, NullLogger.Instance, portCount: 3); + }, loggerFactory.CreateLogger(GetType()), portCount: 4); } finally { @@ -764,7 +774,6 @@ await ServerRetryHelper.BindPortsWithRetry(async ports => } // Assert - Assert.NotNull(testSink); var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName && w.LogLevel >= LogLevel.Information).ToList(); Assert.Collection(l, w => @@ -785,14 +794,21 @@ await ServerRetryHelper.BindPortsWithRetry(async ports => Assert.Equal("OTLP/gRPC listening on: {OtlpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); var uri = new Uri((string)GetValue(w.State, "OtlpEndpointUri")!); - Assert.NotEqual(0, uri.Port); + Assert.Equal(otlpGrpcPort, uri.Port); }, w => { Assert.Equal("OTLP/HTTP listening on: {OtlpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); var uri = new Uri((string)GetValue(w.State, "OtlpEndpointUri")!); - Assert.NotEqual(0, uri.Port); + Assert.Equal(otlpHttpPort, uri.Port); + }, + w => + { + Assert.Equal("MCP listening on: {McpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); + + var uri = new Uri((string)GetValue(w.State, "McpEndpointUri")!); + Assert.NotEqual(0, uri.Port); // Check that allocated port is in log message }, w => { From 07544cec1e71eb7a63f2cbdfecd8b3fce41a3c78 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 31 Oct 2025 09:45:28 +0800 Subject: [PATCH 2/4] Fix extra tests --- .../Integration/FrontendBrowserTokenAuthTests.cs | 7 +++++++ tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 778b5b56edf..bf59b0d1dc1 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -195,6 +195,13 @@ public async Task LogOutput_NoToken_GeneratedTokenLogged() Assert.NotEqual(0, uri.Port); }, w => + { + Assert.Equal("MCP listening on: {McpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); + + var uri = new Uri((string)GetValue(w.State, "McpEndpointUri")!); + Assert.NotEqual(0, uri.Port); + }, + w => { Assert.Equal("OTLP server is unsecured. Untrusted apps can send telemetry to the dashboard. For more information, visit https://go.microsoft.com/fwlink/?linkid=2267030", GetValue(w.State, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index 14ecbe5a104..d82daab15e3 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -709,6 +709,13 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() Assert.NotEqual(0, uri.Port); }, w => + { + Assert.Equal("MCP listening on: {McpEndpointUri}", GetValue(w.State, "{OriginalFormat}")); + + var uri = new Uri((string)GetValue(w.State, "McpEndpointUri")!); + Assert.NotEqual(0, uri.Port); + }, + w => { Assert.Equal("OTLP server is unsecured. Untrusted apps can send telemetry to the dashboard. For more information, visit https://go.microsoft.com/fwlink/?linkid=2267030", GetValue(w.State, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); From dc036180bfbd8f3923c60c817106793c82b8a704 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 31 Oct 2025 09:48:47 +0800 Subject: [PATCH 3/4] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs b/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs index ed0e8782d30..7e3396d9ff1 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/ServerRetryHelper.cs @@ -39,6 +39,9 @@ public static async Task BindPortsWithRetry(Func, Task> retryFunc, ILo var port = GetAvailablePort(nextPortAttempt, logger); ports.Add(port); + // Use a minimum gap of 10 between port allocations to reduce the risk of port collisions. + // Allocating consecutive ports (gap of 0) can lead to conflicts if the OS or other processes + // allocate ports in the same range. The random gap further reduces the chance of collision. nextPortAttempt = port + Random.Shared.Next(10, 100); } From 7375febff65dafffe1ac5257ec858b4fdbad1a89 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 31 Oct 2025 09:48:57 +0800 Subject: [PATCH 4/4] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index d82daab15e3..a1dbc8f5162 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -756,6 +756,8 @@ await ServerRetryHelper.BindPortsWithRetry(async ports => otlpHttpPort = ports[3]; // Reset sink writes. Required to clear out data from a previous failed retry. + // The following cast relies on the internal implementation detail that TestSink.Writes is a ConcurrentQueue. + // If the implementation of TestSink changes, this may break. There is no public API to clear the writes. var writes = (ConcurrentQueue)testSink.Writes; writes.Clear();