diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj
index 8d8ed84f6f3..efac95864cb 100644
--- a/src/Aspire.Cli/Aspire.Cli.csproj
+++ b/src/Aspire.Cli/Aspire.Cli.csproj
@@ -9,7 +9,7 @@
false
aspire
Aspire.Cli
- $(NoWarn);CS1591
+ $(NoWarn);CS1591;CS1574
true
Size
$(DefineConstants);CLI
@@ -89,6 +89,9 @@
+
+
+
diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs
index 052182ecd5f..ecd88b55108 100644
--- a/src/Aspire.Cli/Program.cs
+++ b/src/Aspire.Cli/Program.cs
@@ -289,6 +289,7 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddSingleton();
// MCP server transport factory - creates transport only when needed to avoid
diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs
index 434b5977525..03162a8c7e6 100644
--- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs
+++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs
@@ -3,7 +3,6 @@
using System.Diagnostics;
using System.Net.Sockets;
-using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
@@ -476,84 +475,15 @@ await GenerateCodeViaRpcAsync(
private Dictionary? ReadLaunchSettingsEnvironmentVariables(DirectoryInfo directory)
{
- // For guest apphosts, look for apphost.run.json
- // similar to how .NET single-file apphosts use apphost.run.json
- var apphostRunPath = Path.Combine(directory.FullName, "apphost.run.json");
- var launchSettingsPath = Path.Combine(directory.FullName, "Properties", "launchSettings.json");
-
- var configPath = File.Exists(apphostRunPath) ? apphostRunPath : launchSettingsPath;
-
- if (!File.Exists(configPath))
+ var result = LaunchProfileHelper.ReadEnvironmentVariables(directory);
+ if (result is null)
{
_logger.LogDebug("No apphost.run.json or launchSettings.json found in {Path}", directory.FullName);
return null;
}
- try
- {
- var json = File.ReadAllText(configPath);
- using var doc = JsonDocument.Parse(json);
-
- if (!doc.RootElement.TryGetProperty("profiles", out var profiles))
- {
- return null;
- }
-
- // Try to find the 'https' profile first, then fall back to the first profile
- JsonElement? profileElement = null;
- if (profiles.TryGetProperty("https", out var httpsProfile))
- {
- profileElement = httpsProfile;
- }
- else
- {
- // Use the first profile
- using var enumerator = profiles.EnumerateObject();
- if (enumerator.MoveNext())
- {
- profileElement = enumerator.Current.Value;
- }
- }
-
- if (profileElement == null)
- {
- return null;
- }
-
- var result = new Dictionary();
-
- // Read applicationUrl and convert to ASPNETCORE_URLS
- if (profileElement.Value.TryGetProperty("applicationUrl", out var appUrl) &&
- appUrl.ValueKind == JsonValueKind.String)
- {
- result["ASPNETCORE_URLS"] = appUrl.GetString()!;
- }
-
- // Read environment variables
- if (profileElement.Value.TryGetProperty("environmentVariables", out var envVars))
- {
- foreach (var prop in envVars.EnumerateObject())
- {
- if (prop.Value.ValueKind == JsonValueKind.String)
- {
- result[prop.Name] = prop.Value.GetString()!;
- }
- }
- }
-
- if (result.Count == 0)
- {
- return null;
- }
-
- _logger.LogDebug("Read {Count} environment variables from apphost.run.json", result.Count);
- return result;
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to read launchSettings.json");
- return null;
- }
+ _logger.LogDebug("Read {Count} environment variables from launch profile", result.Count);
+ return result;
}
///
diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/PortAvailabilityCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/PortAvailabilityCheck.cs
new file mode 100644
index 00000000000..1eef9e28210
--- /dev/null
+++ b/src/Aspire.Cli/Utils/EnvironmentChecker/PortAvailabilityCheck.cs
@@ -0,0 +1,385 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Cli.Utils.EnvironmentChecker;
+
+///
+/// Checks if configured ports in apphost.run.json or launchSettings.json fall within
+/// Windows excluded port ranges (e.g., Hyper-V dynamic reservations) or the OS ephemeral port range.
+///
+internal sealed class PortAvailabilityCheck(CliExecutionContext executionContext, ILogger logger) : IEnvironmentCheck
+{
+ private const int ProcessTimeoutMs = 5000;
+ private const int ProcessExitGracePeriodMs = 500;
+
+ public int Order => 45; // After container checks
+
+ public Task> CheckAsync(CancellationToken cancellationToken = default)
+ {
+ var results = new List();
+
+ try
+ {
+ var ports = ReadConfiguredPorts(executionContext.WorkingDirectory);
+ if (ports.Count == 0)
+ {
+ return Task.FromResult>([]);
+ }
+
+ // On Windows, check if any configured ports fall within OS-excluded port ranges
+ // (e.g., Hyper-V dynamic reservations that prevent any process from binding)
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ var excludedRanges = GetExcludedPortRanges();
+ var excludedPorts = ports.Where(p => IsPortExcluded(p.Port, excludedRanges)).ToList();
+
+ if (excludedPorts.Count > 0)
+ {
+ var portList = string.Join(", ", excludedPorts.Select(p => $"{p.Port} ({p.Source})"));
+ results.Add(new EnvironmentCheckResult
+ {
+ Category = "environment",
+ Name = "port-availability",
+ Status = EnvironmentCheckStatus.Warning,
+ Message = $"Configured ports in excluded range: {portList}",
+ Details = "These ports fall within a Windows excluded port range (often reserved by Hyper-V). No process can bind to these ports, so the dashboard link displayed by 'aspire run' will not work.",
+ Fix = "Delete apphost.run.json (or launchSettings.json) and run 'aspire run' to auto-assign available ports, or update the file with ports outside excluded ranges.\nRun 'netsh interface ipv4 show excludedportrange protocol=tcp' to see reserved port ranges."
+ });
+ }
+ }
+
+ // Check if any configured ports fall in the ephemeral port range (high risk of random conflicts)
+ var ephemeralRange = GetEphemeralPortRange();
+ if (ephemeralRange is not null)
+ {
+ var ephemeralPorts = ports
+ .Where(p => p.Port >= ephemeralRange.Value.Start && p.Port <= ephemeralRange.Value.End)
+ .ToList();
+
+ if (ephemeralPorts.Count > 0)
+ {
+ var portList = string.Join(", ", ephemeralPorts.Select(p => $"{p.Port} ({p.Source})"));
+ results.Add(new EnvironmentCheckResult
+ {
+ Category = "environment",
+ Name = "ephemeral-port-range",
+ Status = EnvironmentCheckStatus.Warning,
+ Message = $"Configured ports in ephemeral range: {portList}",
+ Details = $"These ports fall within the OS ephemeral port range ({ephemeralRange.Value.Start}-{ephemeralRange.Value.End}), which is used for outgoing connections and randomly assigned ports. This increases the chance of port conflicts.",
+ Fix = "Consider using ports outside the ephemeral range. Delete apphost.run.json (or launchSettings.json) and run 'aspire run' to auto-assign available ports, or update the file with ports below the ephemeral range (e.g., 15000-15100)."
+ });
+ }
+ }
+
+ if (results.Count == 0)
+ {
+ results.Add(new EnvironmentCheckResult
+ {
+ Category = "environment",
+ Name = "port-availability",
+ Status = EnvironmentCheckStatus.Pass,
+ Message = "No port conflicts detected"
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "Failed to check port availability");
+
+ results.Add(new EnvironmentCheckResult
+ {
+ Category = "environment",
+ Name = "port-availability",
+ Status = EnvironmentCheckStatus.Warning,
+ Message = "Unable to check port availability",
+ Details = ex.Message
+ });
+ }
+
+ return Task.FromResult>(results);
+ }
+
+ ///
+ /// Reads configured ports from apphost.run.json or launchSettings.json using shared launch profile parsing.
+ ///
+ internal static List<(int Port, string Source)> ReadConfiguredPorts(DirectoryInfo workingDirectory)
+ {
+ var envVars = LaunchProfileHelper.ReadEnvironmentVariables(workingDirectory);
+ if (envVars is null)
+ {
+ return [];
+ }
+
+ return ExtractPortsFromEnvironmentVariables(envVars);
+ }
+
+ ///
+ /// Extracts port numbers from launch profile environment variables.
+ /// Any value that parses as an absolute URL with a port is included.
+ /// ASPNETCORE_URLS (mapped from applicationUrl) may contain semicolon-separated URLs.
+ ///
+ internal static List<(int Port, string Source)> ExtractPortsFromEnvironmentVariables(Dictionary envVars)
+ {
+ var ports = new List<(int Port, string Source)>();
+
+ foreach (var (key, value) in envVars)
+ {
+ if (key == "ASPNETCORE_URLS")
+ {
+ // applicationUrl is mapped to ASPNETCORE_URLS and may contain multiple semicolon-separated URLs
+ foreach (var url in value.Split(';', StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) && uri.Port > 0)
+ {
+ ports.Add((uri.Port, "applicationUrl"));
+ }
+ }
+ }
+ else if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Port > 0 && uri.Scheme is "http" or "https")
+ {
+ ports.Add((uri.Port, key));
+ }
+ }
+
+ return ports;
+ }
+
+ ///
+ /// Runs a command and returns its stdout, or null if it fails or times out.
+ ///
+ private static string? RunCommand(string fileName, string arguments)
+ {
+ try
+ {
+ using var process = new Process();
+ process.StartInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ process.Start();
+
+ var outputTask = process.StandardOutput.ReadToEndAsync();
+ if (!outputTask.Wait(ProcessTimeoutMs))
+ {
+ try { process.Kill(); } catch { }
+ return null;
+ }
+
+ process.WaitForExit(ProcessExitGracePeriodMs);
+ return outputTask.Result;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Gets the Windows excluded port ranges using netsh.
+ ///
+ internal static List<(int Start, int End)> GetExcludedPortRanges()
+ {
+ var output = RunCommand("netsh", "interface ipv4 show excludedportrange protocol=tcp");
+ return output is not null ? ParseExcludedPortRanges(output) : [];
+ }
+
+ ///
+ /// Parses the output of 'netsh interface ipv4 show excludedportrange protocol=tcp'.
+ ///
+ ///
+ /// Expected format:
+ ///
+ /// Protocol tcp Port Exclusion Ranges
+ ///
+ /// Start Port End Port
+ /// ---------- --------
+ /// 1080 1179
+ /// 1180 1279
+ /// 50000 50099 *
+ /// 56224 56323
+ ///
+ /// * - Administered port exclusions.
+ ///
+ /// Lines with two leading integers are parsed as port ranges.
+ /// Header rows, dashes, and "* - Administered" lines are skipped automatically
+ /// because they don't start with two parseable integers.
+ ///
+ internal static List<(int Start, int End)> ParseExcludedPortRanges(string netshOutput)
+ {
+ var ranges = new List<(int Start, int End)>();
+
+ foreach (var line in netshOutput.Split('\n'))
+ {
+ var trimmed = line.Trim();
+ var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length >= 2 &&
+ int.TryParse(parts[0], out var start) &&
+ int.TryParse(parts[1], out var end))
+ {
+ ranges.Add((start, end));
+ }
+ }
+
+ return ranges;
+ }
+
+ ///
+ /// Gets the OS ephemeral (dynamic) port range.
+ /// On Windows: uses 'netsh int ipv4 show dynamicport tcp'.
+ /// On Linux: reads /proc/sys/net/ipv4/ip_local_port_range.
+ /// On macOS: uses sysctl net.inet.ip.portrange.first/last.
+ ///
+ internal static (int Start, int End)? GetEphemeralPortRange()
+ {
+ try
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return GetWindowsEphemeralPortRange();
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ return GetLinuxEphemeralPortRange();
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return GetMacOSEphemeralPortRange();
+ }
+ }
+ catch
+ {
+ // Fall through to default
+ }
+
+ // IANA default ephemeral range
+ return (49152, 65535);
+ }
+
+ private static (int Start, int End)? GetWindowsEphemeralPortRange()
+ {
+ var output = RunCommand("netsh", "int ipv4 show dynamicport tcp");
+ return output is not null ? ParseDynamicPortRange(output) : null;
+ }
+
+ ///
+ /// Parses the output of 'netsh int ipv4 show dynamicport tcp'.
+ ///
+ ///
+ /// Expected format:
+ ///
+ /// Protocol tcp Dynamic Port Range
+ /// ---------------------------------
+ /// Start Port : 49152
+ /// Number of Ports : 16384
+ ///
+ /// The end port is calculated as Start Port + Number of Ports - 1 (e.g., 49152 + 16384 - 1 = 65535).
+ ///
+ internal static (int Start, int End)? ParseDynamicPortRange(string netshOutput)
+ {
+ int? startPort = null;
+ int? numberOfPorts = null;
+
+ foreach (var line in netshOutput.Split('\n'))
+ {
+ var trimmed = line.Trim();
+ if (trimmed.StartsWith("Start Port", StringComparison.OrdinalIgnoreCase))
+ {
+ var colonIdx = trimmed.IndexOf(':');
+ if (colonIdx >= 0 && int.TryParse(trimmed[(colonIdx + 1)..].Trim(), out var val))
+ {
+ startPort = val;
+ }
+ }
+ else if (trimmed.StartsWith("Number of Ports", StringComparison.OrdinalIgnoreCase))
+ {
+ var colonIdx = trimmed.IndexOf(':');
+ if (colonIdx >= 0 && int.TryParse(trimmed[(colonIdx + 1)..].Trim(), out var val))
+ {
+ numberOfPorts = val;
+ }
+ }
+ }
+
+ if (startPort.HasValue && numberOfPorts.HasValue)
+ {
+ return (startPort.Value, startPort.Value + numberOfPorts.Value - 1);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Reads the ephemeral port range on Linux from /proc/sys/net/ipv4/ip_local_port_range.
+ ///
+ ///
+ /// Expected format (tab-separated):
+ ///
+ /// 32768 60999
+ ///
+ ///
+ private static (int Start, int End)? GetLinuxEphemeralPortRange()
+ {
+ const string path = "/proc/sys/net/ipv4/ip_local_port_range";
+ if (!File.Exists(path))
+ {
+ return null;
+ }
+
+ var content = File.ReadAllText(path).Trim();
+ var parts = content.Split('\t', ' ');
+ if (parts.Length >= 2 &&
+ int.TryParse(parts[0].Trim(), out var start) &&
+ int.TryParse(parts[^1].Trim(), out var end))
+ {
+ return (start, end);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Reads the ephemeral port range on macOS via sysctl.
+ ///
+ ///
+ /// Each sysctl key returns a single integer:
+ ///
+ /// $ sysctl -n net.inet.ip.portrange.first
+ /// 49152
+ /// $ sysctl -n net.inet.ip.portrange.last
+ /// 65535
+ ///
+ ///
+ private static (int Start, int End)? GetMacOSEphemeralPortRange()
+ {
+ static int? ReadSysctl(string key)
+ {
+ var output = RunCommand("sysctl", $"-n {key}");
+ return output is not null && int.TryParse(output.Trim(), out var val) ? val : null;
+ }
+
+ var first = ReadSysctl("net.inet.ip.portrange.first");
+ var last = ReadSysctl("net.inet.ip.portrange.last");
+
+ return (first.HasValue && last.HasValue) ? (first.Value, last.Value) : null;
+ }
+
+ private static bool IsPortExcluded(int port, List<(int Start, int End)> excludedRanges)
+ {
+ foreach (var (start, end) in excludedRanges)
+ {
+ if (port >= start && port <= end)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/Aspire.Cli/Utils/LaunchProfileHelper.cs b/src/Aspire.Cli/Utils/LaunchProfileHelper.cs
new file mode 100644
index 00000000000..c2bfe90f043
--- /dev/null
+++ b/src/Aspire.Cli/Utils/LaunchProfileHelper.cs
@@ -0,0 +1,107 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using Aspire.Hosting;
+
+namespace Aspire.Cli.Utils;
+
+///
+/// Shared helper for reading apphost.run.json or launchSettings.json launch profiles.
+///
+internal static class LaunchProfileHelper
+{
+ ///
+ /// Reads environment variables from apphost.run.json or launchSettings.json in the given directory.
+ /// Prefers apphost.run.json, falls back to Properties/launchSettings.json.
+ /// Selects the "https" profile first, then falls back to the first profile.
+ /// The applicationUrl property is mapped to ASPNETCORE_URLS.
+ ///
+ /// A dictionary of environment variables, or null if no config file was found or it couldn't be parsed.
+ public static Dictionary? ReadEnvironmentVariables(DirectoryInfo directory)
+ {
+ var configPath = ResolveConfigPath(directory);
+ if (configPath is null)
+ {
+ return null;
+ }
+
+ return ReadEnvironmentVariables(configPath);
+ }
+
+ ///
+ /// Reads environment variables from the given launch profile JSON file.
+ ///
+ public static Dictionary? ReadEnvironmentVariables(string configPath)
+ {
+ try
+ {
+ using var stream = File.OpenRead(configPath);
+ var settings = JsonSerializer.Deserialize(stream, LaunchSettingsSerializerContext.Default.LaunchSettings);
+
+ if (settings is null || settings.Profiles.Count == 0)
+ {
+ return null;
+ }
+
+ var profile = SelectProfile(settings);
+ if (profile is null)
+ {
+ return null;
+ }
+
+ var result = new Dictionary();
+
+ // Read applicationUrl and convert to ASPNETCORE_URLS
+ if (!string.IsNullOrEmpty(profile.ApplicationUrl))
+ {
+ result["ASPNETCORE_URLS"] = profile.ApplicationUrl;
+ }
+
+ // Read environment variables
+ foreach (var (key, value) in profile.EnvironmentVariables)
+ {
+ result[key] = value;
+ }
+
+ return result.Count == 0 ? null : result;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Resolves the config file path, preferring apphost.run.json over Properties/launchSettings.json.
+ ///
+ /// The path to the config file, or null if neither exists.
+ public static string? ResolveConfigPath(DirectoryInfo directory)
+ {
+ var apphostRunPath = Path.Combine(directory.FullName, "apphost.run.json");
+ if (File.Exists(apphostRunPath))
+ {
+ return apphostRunPath;
+ }
+
+ var launchSettingsPath = Path.Combine(directory.FullName, "Properties", "launchSettings.json");
+ if (File.Exists(launchSettingsPath))
+ {
+ return launchSettingsPath;
+ }
+
+ return null;
+ }
+
+ private static LaunchProfile? SelectProfile(LaunchSettings settings)
+ {
+ // Try to find the 'https' profile first, then fall back to the first profile
+ if (settings.Profiles.TryGetValue("https", out var httpsProfile))
+ {
+ return httpsProfile;
+ }
+
+ using var enumerator = settings.Profiles.GetEnumerator();
+ return enumerator.MoveNext() ? enumerator.Current.Value : null;
+ }
+}
diff --git a/tests/Aspire.Cli.Tests/Utils/PortAvailabilityCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/PortAvailabilityCheckTests.cs
new file mode 100644
index 00000000000..abcd4c1bb7a
--- /dev/null
+++ b/tests/Aspire.Cli.Tests/Utils/PortAvailabilityCheckTests.cs
@@ -0,0 +1,298 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Cli.Utils.EnvironmentChecker;
+
+namespace Aspire.Cli.Tests.Utils;
+
+public class PortAvailabilityCheckTests
+{
+ [Fact]
+ public void ReadConfiguredPorts_WithValidApphostRunJson_ReturnsPorts()
+ {
+ // Arrange
+ var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-");
+ try
+ {
+ var json = """
+ {
+ "profiles": {
+ "https": {
+ "applicationUrl": "https://localhost:15000;http://localhost:15001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:15002",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:15003"
+ }
+ }
+ }
+ }
+ """;
+ File.WriteAllText(Path.Combine(tempDir.FullName, "apphost.run.json"), json);
+
+ // Act
+ var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir);
+
+ // Assert
+ Assert.Contains(ports, p => p.Port == 15000 && p.Source == "applicationUrl");
+ Assert.Contains(ports, p => p.Port == 15001 && p.Source == "applicationUrl");
+ Assert.Contains(ports, p => p.Port == 15002 && p.Source == "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL");
+ Assert.Contains(ports, p => p.Port == 15003 && p.Source == "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL");
+ Assert.Equal(4, ports.Count);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void ReadConfiguredPorts_WithNoConfigFile_ReturnsEmpty()
+ {
+ // Arrange
+ var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-");
+ try
+ {
+ // Act
+ var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir);
+
+ // Assert
+ Assert.Empty(ports);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void ReadConfiguredPorts_WithLaunchSettings_ReturnsPorts()
+ {
+ // Arrange
+ var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-");
+ try
+ {
+ var propsDir = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "Properties"));
+ var json = """
+ {
+ "profiles": {
+ "https": {
+ "applicationUrl": "https://localhost:5001;http://localhost:5000"
+ }
+ }
+ }
+ """;
+ File.WriteAllText(Path.Combine(propsDir.FullName, "launchSettings.json"), json);
+
+ // Act
+ var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir);
+
+ // Assert
+ Assert.Contains(ports, p => p.Port == 5001);
+ Assert.Contains(ports, p => p.Port == 5000);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void ReadConfiguredPorts_PrefersApphostRunJson_OverLaunchSettings()
+ {
+ // Arrange
+ var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-");
+ try
+ {
+ // Create both files with different ports
+ File.WriteAllText(Path.Combine(tempDir.FullName, "apphost.run.json"), """
+ {
+ "profiles": {
+ "https": {
+ "applicationUrl": "https://localhost:9999"
+ }
+ }
+ }
+ """);
+
+ var propsDir = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "Properties"));
+ File.WriteAllText(Path.Combine(propsDir.FullName, "launchSettings.json"), """
+ {
+ "profiles": {
+ "https": {
+ "applicationUrl": "https://localhost:8888"
+ }
+ }
+ }
+ """);
+
+ // Act
+ var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir);
+
+ // Assert - should use apphost.run.json
+ Assert.Contains(ports, p => p.Port == 9999);
+ Assert.DoesNotContain(ports, p => p.Port == 8888);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void ReadConfiguredPorts_FallsBackToFirstProfile_WhenNoHttpsProfile()
+ {
+ // Arrange
+ var tempDir = Directory.CreateTempSubdirectory("aspire-port-test-");
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir.FullName, "apphost.run.json"), """
+ {
+ "profiles": {
+ "default": {
+ "applicationUrl": "http://localhost:7777"
+ }
+ }
+ }
+ """);
+
+ // Act
+ var ports = PortAvailabilityCheck.ReadConfiguredPorts(tempDir);
+
+ // Assert
+ Assert.Contains(ports, p => p.Port == 7777);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+
+ [Fact]
+ public void ParseExcludedPortRanges_WithTypicalNetshOutput_ParsesRanges()
+ {
+ var output = """
+
+ Protocol tcp Port Exclusion Ranges
+
+ Start Port End Port
+ ---------- --------
+ 1080 1179
+ 1180 1279
+ 50000 50099 *
+ 56224 56323
+
+ * - Administered port exclusions.
+
+ """;
+
+ var ranges = PortAvailabilityCheck.ParseExcludedPortRanges(output);
+
+ Assert.Equal(4, ranges.Count);
+ Assert.Contains(ranges, r => r.Start == 1080 && r.End == 1179);
+ Assert.Contains(ranges, r => r.Start == 1180 && r.End == 1279);
+ Assert.Contains(ranges, r => r.Start == 50000 && r.End == 50099);
+ Assert.Contains(ranges, r => r.Start == 56224 && r.End == 56323);
+ }
+
+ [Fact]
+ public void ParseExcludedPortRanges_WithEmptyOutput_ReturnsEmpty()
+ {
+ var ranges = PortAvailabilityCheck.ParseExcludedPortRanges("");
+ Assert.Empty(ranges);
+ }
+
+ [Fact]
+ public void ParseExcludedPortRanges_WithHeadersOnly_ReturnsEmpty()
+ {
+ var output = """
+
+ Protocol tcp Port Exclusion Ranges
+
+ Start Port End Port
+ ---------- --------
+
+ * - Administered port exclusions.
+
+ """;
+
+ var ranges = PortAvailabilityCheck.ParseExcludedPortRanges(output);
+ Assert.Empty(ranges);
+ }
+
+ [Fact]
+ public void ExtractPortsFromEnvironmentVariables_ExtractsUrlsAndEndpoints()
+ {
+ var envVars = new Dictionary
+ {
+ ["ASPNETCORE_URLS"] = "https://localhost:15000;http://localhost:15001",
+ ["ASPNETCORE_ENVIRONMENT"] = "Development",
+ ["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"] = "https://localhost:15002",
+ ["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"] = "https://localhost:15003"
+ };
+
+ var ports = PortAvailabilityCheck.ExtractPortsFromEnvironmentVariables(envVars);
+
+ Assert.Equal(4, ports.Count);
+ Assert.Contains(ports, p => p.Port == 15000 && p.Source == "applicationUrl");
+ Assert.Contains(ports, p => p.Port == 15001 && p.Source == "applicationUrl");
+ Assert.Contains(ports, p => p.Port == 15002 && p.Source == "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL");
+ Assert.Contains(ports, p => p.Port == 15003 && p.Source == "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL");
+ }
+
+ [Fact]
+ public void ExtractPortsFromEnvironmentVariables_IgnoresNonUrlVars()
+ {
+ var envVars = new Dictionary
+ {
+ ["ASPNETCORE_ENVIRONMENT"] = "Development",
+ ["DOTNET_ENVIRONMENT"] = "Development"
+ };
+
+ var ports = PortAvailabilityCheck.ExtractPortsFromEnvironmentVariables(envVars);
+ Assert.Empty(ports);
+ }
+
+ [Fact]
+ public void ParseDynamicPortRange_WithTypicalOutput_ParsesRange()
+ {
+ var output = """
+
+ Protocol tcp Dynamic Port Range
+ ---------------------------------
+ Start Port : 49152
+ Number of Ports : 16384
+
+ """;
+
+ var range = PortAvailabilityCheck.ParseDynamicPortRange(output);
+
+ Assert.NotNull(range);
+ Assert.Equal(49152, range.Value.Start);
+ Assert.Equal(65535, range.Value.End);
+ }
+
+ [Fact]
+ public void ParseDynamicPortRange_WithEmptyOutput_ReturnsNull()
+ {
+ var range = PortAvailabilityCheck.ParseDynamicPortRange("");
+ Assert.Null(range);
+ }
+
+ [Fact]
+ public void ParseDynamicPortRange_WithCustomRange_ParsesCorrectly()
+ {
+ var output = """
+ Protocol tcp Dynamic Port Range
+ ---------------------------------
+ Start Port : 1024
+ Number of Ports : 64511
+ """;
+
+ var range = PortAvailabilityCheck.ParseDynamicPortRange(output);
+
+ Assert.NotNull(range);
+ Assert.Equal(1024, range.Value.Start);
+ Assert.Equal(65534, range.Value.End);
+ }
+}