// 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.Cli.EndToEnd.Tests.Helpers;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
public sealed class LaunchSettingsPortHelpersTests : IDisposable
{
// Shape of the launch settings generated by `aspire new` for a starter AppHost.
private const string AppHostLaunchSettings = """
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17179;http://localhost:15203",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21151",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22001"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15203",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19021",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20187"
}
}
}
}
""";
private const string ApiServiceLaunchSettings = """
{
"profiles": {
"https": {
"commandName": "Project",
"applicationUrl": "https://app.dev.localhost:7411;http://app.dev.localhost:5402"
}
}
}
""";
private readonly DirectoryInfo _root = Directory.CreateTempSubdirectory("aspire-launch-ports-");
public void Dispose() => _root.Delete(recursive: true);
[Fact]
public void MakePortsDistinct_RewritesPortsSharedWithOtherProject()
{
// Worst case of the template's random port choice: both projects got identical ports.
var app1 = CreateProject("App1", AppHostLaunchSettings, ApiServiceLaunchSettings);
var app2 = CreateProject("App2", AppHostLaunchSettings, ApiServiceLaunchSettings);
Assert.Contains(22001, GetPorts(app2));
var mapping = LaunchSettingsPortHelpers.MakePortsDistinct(app2, app1);
var app1Ports = GetPorts(app1);
var app2Ports = GetPorts(app2);
Assert.Empty(app1Ports.Intersect(app2Ports));
Assert.Equal(app1Ports.Count, app2Ports.Count);
Assert.Equal(app1Ports.Order(), mapping.Keys.Order());
// App1 is untouched; App2 keeps its content apart from the remapped ports, and a port that is
// repeated across profiles maps to the same new port everywhere.
Assert.Equal(AppHostLaunchSettings, File.ReadAllText(GetAppHostFile(app1)));
var expectedAppHost = AppHostLaunchSettings;
foreach (var (oldPort, newPort) in mapping.OrderByDescending(m => m.Key))
{
expectedAppHost = expectedAppHost.Replace($":{oldPort}", $":{newPort}", StringComparison.Ordinal);
}
Assert.Equal(expectedAppHost, File.ReadAllText(GetAppHostFile(app2)));
foreach (var file in LaunchSettingsPortHelpers.GetLaunchSettingsFiles(app2))
{
using var _ = JsonDocument.Parse(File.ReadAllText(file));
}
}
[Fact]
public void MakePortsDistinct_SkipsPortsUsedByEitherProject()
{
var app1 = CreateProject("App1", """{ "a": "https://localhost:22001", "b": "https://localhost:22002" }""");
var app2 = CreateProject("App2", """{ "a": "https://localhost:22001", "b": "https://localhost:22003" }""");
var mapping = LaunchSettingsPortHelpers.MakePortsDistinct(app2, app1);
Assert.Equal(new Dictionary<int, int> { [22001] = 22004 }, mapping);
Assert.Equal("""{ "a": "https://localhost:22004", "b": "https://localhost:22003" }""", File.ReadAllText(GetAppHostFile(app2)));
}
[Fact]
public void MakePortsDistinct_LeavesProjectWithoutSharedPortsUnchanged()
{
var app1 = CreateProject("App1", AppHostLaunchSettings);
var distinct = """{ "a": "https://localhost:17000", "b": "https://localhost:22000" }""";
var app2 = CreateProject("App2", distinct);
var mapping = LaunchSettingsPortHelpers.MakePortsDistinct(app2, app1);
Assert.Empty(mapping);
Assert.Equal(distinct, File.ReadAllText(GetAppHostFile(app2)));
}
private DirectoryInfo CreateProject(string name, string appHostLaunchSettings, string? apiServiceLaunchSettings = null)
{
var project = _root.CreateSubdirectory(name);
WriteLaunchSettings(project, $"{name}.AppHost", appHostLaunchSettings);
if (apiServiceLaunchSettings is not null)
{
WriteLaunchSettings(project, $"{name}.ApiService", apiServiceLaunchSettings);
}
return project;
}
private static void WriteLaunchSettings(DirectoryInfo project, string projectName, string content)
{
var properties = project.CreateSubdirectory(Path.Combine(projectName, "Properties"));
File.WriteAllText(Path.Combine(properties.FullName, "launchSettings.json"), content);
}
private static string GetAppHostFile(DirectoryInfo project) =>
Path.Combine(project.FullName, $"{project.Name}.AppHost", "Properties", "launchSettings.json");
private static HashSet<int> GetPorts(DirectoryInfo project) =>
LaunchSettingsPortHelpers.GetLaunchSettingsFiles(project)
.SelectMany(f => LaunchSettingsPortHelpers.GetPorts(File.ReadAllText(f)))
.ToHashSet();
}