// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.AspNetCore.InternalTesting;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using Aspire.Cli.Configuration;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Cli.Tests.Projects;
public class AppHostServerProjectTests(ITestOutputHelper outputHelper) : IDisposable
{
private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.CreateForCli(outputHelper);
public void Dispose()
{
_workspace.Dispose();
GC.SuppressFinalize(this);
}
private DotNetBasedAppHostServerProject CreateProject(string? appPath = null)
{
appPath ??= _workspace.WorkspaceRoot.FullName;
var runner = new TestDotNetCliRunner();
var packagingService = MockPackagingServiceFactory.Create();
var logger = NullLogger<DotNetBasedAppHostServerProject>.Instance;
// Generate socket path same way as factory
var socketPath = "test.sock";
// Use workspace root as repo root for testing
var repoRoot = _workspace.WorkspaceRoot.FullName;
return new DotNetBasedAppHostServerProject(appPath, socketPath, repoRoot, runner, packagingService, new TestProcessExecutionFactory(), new TestEnvironment(), logger);
}
[Fact]
public async Task CreateProjectFiles_AppSettingsJson_MatchesSnapshot()
{
// Arrange
var project = CreateProject();
var packages = new List<IntegrationReference>
{
IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"),
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0"),
IntegrationReference.FromPackage("Aspire.Hosting.PostgreSQL", "13.1.0"),
IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0")
};
// Act
await project.CreateProjectFilesAsync(packages).DefaultTimeout();
// Assert
var appSettingsPath = Path.Combine(project.ProjectModelPath, "appsettings.json");
var content = await File.ReadAllTextAsync(appSettingsPath);
await Verify(content, extension: "json")
.UseFileName("AppHostServerProject_AppSettingsJson");
}
[Fact]
public async Task CreateProjectFiles_ProgramCs_MatchesSnapshot()
{
// Arrange
var project = CreateProject();
var packages = new List<IntegrationReference>
{
IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
};
// Act
await project.CreateProjectFilesAsync(packages).DefaultTimeout();
// Assert
var programCsPath = Path.Combine(project.ProjectModelPath, "Program.cs");
var content = await File.ReadAllTextAsync(programCsPath);
// Use .txt extension to avoid compilation of snapshot file
await Verify(content, extension: "txt")
.UseFileName("AppHostServerProject_ProgramCs");
}
[Fact]
public async Task CreateProjectFiles_GeneratesProgramCs()
{
// Arrange
var project = CreateProject();
var packages = new List<IntegrationReference>
{
IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
};
// Act
await project.CreateProjectFilesAsync(packages).DefaultTimeout();
// Assert
var programCs = Path.Combine(project.ProjectModelPath, "Program.cs");
Assert.True(File.Exists(programCs));
var content = await File.ReadAllTextAsync(programCs);
Assert.Contains("RemoteHostServer.RunAsync", content);
}
[Fact]
public async Task CreateProjectFiles_GeneratesAppSettingsJson_WithAtsAssemblies()
{
// Arrange
var project = CreateProject();
var packages = new List<IntegrationReference>
{
IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"),
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0"),
IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0")
};
// Act
await project.CreateProjectFilesAsync(packages).DefaultTimeout();
// Assert
var appSettingsPath = Path.Combine(project.ProjectModelPath, "appsettings.json");
Assert.True(File.Exists(appSettingsPath));
var content = await File.ReadAllTextAsync(appSettingsPath);
Assert.Contains("AtsAssemblies", content);
Assert.Contains("Aspire.Hosting", content);
Assert.Contains("Aspire.Hosting.Redis", content);
Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript", content);
}
[Fact]
public async Task CreateProjectFiles_CopiesAppSettingsToOutput()
{
// Arrange
var project = CreateProject();
var packages = new List<IntegrationReference>
{
IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
};
// Act
var (projectPath, _) = await project.CreateProjectFilesAsync(packages).DefaultTimeout();
// Assert
var doc = XDocument.Load(projectPath);
var noneElement = doc.Descendants("None")
.FirstOrDefault(e => e.Attribute("Include")?.Value == "appsettings.json");
Assert.NotNull(noneElement);
Assert.Equal("PreserveNewest", noneElement.Attribute("CopyToOutputDirectory")?.Value);
}
[Fact]
public async Task CreateProjectFiles_SkipsAspireIntegrationAnalyzerReferences()
{
var project = CreateProject();
var packages = new List<IntegrationReference>
{
IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
};
var (projectPath, _) = await project.CreateProjectFilesAsync(packages).DefaultTimeout();
var doc = XDocument.Load(projectPath);
var skipAnalyzersElement = doc.Descendants("SkipAspireIntegrationAnalyzersReference").SingleOrDefault();
Assert.NotNull(skipAnalyzersElement);
Assert.Equal("true", skipAnalyzersElement.Value);
}
[Fact]
public async Task CreateProjectFiles_ExactAspirePackageRestoresInsteadOfUsingCheckoutProject()
{
var project = CreateProject();
var integrations = new[]
{
IntegrationReference.FromPackage(
"Aspire.Hosting.Redis",
"[13.1.0]",
disableLocalProjectSubstitution: true),
IntegrationReference.FromPackage("Aspire.Hosting.PostgreSQL", "13.1.0")
};
var (projectPath, _) = await project.CreateProjectFilesAsync(integrations).DefaultTimeout();
var document = XDocument.Load(projectPath);
var packageReference = Assert.Single(
document.Descendants("PackageReference"),
element => element.Attribute("Include")?.Value == "Aspire.Hosting.Redis");
Assert.Equal("[13.1.0]", packageReference.Attribute("VersionOverride")?.Value);
Assert.Null(packageReference.Attribute("Version"));
Assert.DoesNotContain(
document.Descendants("PackageReference"),
element => element.Attribute("Include")?.Value == "Aspire.Hosting.PostgreSQL");
}
[Fact]
public async Task CreateProjectFiles_ExactVersionRangeUsesCheckoutProjectByDefault()
{
var integrationDirectory = _workspace.WorkspaceRoot.CreateSubdirectory(
Path.Combine("src", "Aspire.Hosting.Redis"));
var integrationProjectPath = Path.Combine(integrationDirectory.FullName, "Aspire.Hosting.Redis.csproj");
await File.WriteAllTextAsync(integrationProjectPath, "<Project />");
var project = CreateProject();
var integrations = new[]
{
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "[13.1.0]")
};
var (projectPath, _) = await project.CreateProjectFilesAsync(integrations).DefaultTimeout();
var document = XDocument.Load(projectPath);
var projectReference = Assert.Single(
document.Descendants("ProjectReference"),
element => element.Attribute("Include")?.Value == integrationProjectPath);
Assert.Equal("false", projectReference.Element("IsAspireProjectResource")?.Value);
Assert.DoesNotContain(
document.Descendants("PackageReference"),
element => element.Attribute("Include")?.Value == "Aspire.Hosting.Redis");
}
[Fact]
public void ProjectModelPath_IsStableForSameAppPath()
{
// Arrange
var appPath = _workspace.WorkspaceRoot.FullName;
// Act
var project1 = CreateProject(appPath);
var project2 = CreateProject(appPath);
// Assert - same app path should result in same project model path
Assert.Equal(project1.ProjectModelPath, project2.ProjectModelPath);
}
[Fact]
public void ProjectModelPath_UsesUserAspireDirectory()
{
// Arrange
var project = CreateProject();
var normalizedAppPath = Path.GetFullPath(project.AppDirectoryPath);
normalizedAppPath = new Uri(normalizedAppPath).LocalPath;
normalizedAppPath = OperatingSystem.IsWindows() ? normalizedAppPath.ToLowerInvariant() : normalizedAppPath;
var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(normalizedAppPath));
var pathDir = Convert.ToHexString(pathHash)[..12].ToLowerInvariant();
var expectedRoot = Path.Combine(CliPathHelper.GetAspireHomeDirectory(), "hosts");
var expectedPath = Path.Combine(expectedRoot, pathDir);
var parentDirectory = Path.GetDirectoryName(project.ProjectModelPath);
var isSafeToDelete = string.Equals(project.ProjectModelPath, expectedPath, StringComparison.OrdinalIgnoreCase) &&
parentDirectory is not null &&
string.Equals(parentDirectory, expectedRoot, StringComparison.OrdinalIgnoreCase);
try
{
// Assert
Assert.Equal(expectedPath, project.ProjectModelPath);
}
finally
{
if (isSafeToDelete && Directory.Exists(project.ProjectModelPath))
{
Directory.Delete(project.ProjectModelPath, recursive: true);
}
}
}
[Fact]
public void UserSecretsId_IsStableForSameAppPath()
{
// Arrange
var appPath = _workspace.WorkspaceRoot.FullName;
// Act
var project1 = CreateProject(appPath);
var project2 = CreateProject(appPath);
// Assert - same app path should result in same user secrets ID
Assert.Equal(project1.UserSecretsId, project2.UserSecretsId);
}
/// <summary>
/// Regression test for channel switching bug.
/// When a project has a channel configured in aspire.config.json (project-local),
/// the NuGet.config should use that channel's hive path, NOT the global config channel.
///
/// Bug scenario:
/// 1. User runs `aspire update` and selects "pr-new" channel
/// 2. UpdatePackagesAsync saves channel="pr-new" to project-local aspire.config.json
/// 3. BuildAndGenerateSdkAsync calls CreateProjectFilesAsync
/// 4. BUG: CreateProjectFilesAsync reads channel from GLOBAL config (returns "pr-old")
/// 5. NuGet.config is generated with pr-old hive path instead of pr-new
/// 6. Build fails because packages are in pr-new hive but NuGet.config points to pr-old
/// </summary>
[Fact]
public async Task CreateProjectFiles_NuGetConfig_UsesProjectLocalChannel_NotGlobalChannel_MatchesSnapshot()
{
// Arrange
var appPath = _workspace.WorkspaceRoot.FullName;
// Create two PR hive directories to simulate having multiple PR builds
var hivesDir = _workspace.WorkspaceRoot.CreateSubdirectory("hives");
var prOldHive = hivesDir.CreateSubdirectory("pr-old");
var prNewHive = hivesDir.CreateSubdirectory("pr-new");
// Create project-local aspire.config.json with channel="pr-new"
// This simulates what happens after `aspire update` saves the selected channel
var aspireConfigPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "pr-new",
"sdk": {
"version": "13.1.0"
}
}
""");
// Create a packaging service that returns explicit channels for both PR hives
var prOldHivePath = prOldHive.FullName;
var prNewHivePath = prNewHive.FullName;
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var nugetCache = new FakeNuGetPackageCache();
var prOldChannel = PackageChannel.CreateExplicitChannel("pr-old", PackageChannelQuality.Prerelease, new[]
{
new PackageMapping("Aspire*", prOldHivePath),
new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
}, nugetCache, new TestFeatures(), NullLogger.Instance);
var prNewChannel = PackageChannel.CreateExplicitChannel("pr-new", PackageChannelQuality.Prerelease, new[]
{
new PackageMapping("Aspire*", prNewHivePath),
new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
}, nugetCache, new TestFeatures(), NullLogger.Instance);
var implicitChannel = PackageChannel.CreateImplicitChannel(nugetCache, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>(new[] { implicitChannel, prOldChannel, prNewChannel });
}
};
var runner = new TestDotNetCliRunner();
// Use a real logger to capture debug output for diagnostics
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Debug);
builder.AddXunit(outputHelper);
});
var logger = loggerFactory.CreateLogger<DotNetBasedAppHostServerProject>();
// Use a workspace-local ProjectModelPath for test isolation
var projectModelPath = Path.Combine(appPath, ".aspire_server");
var project = new DotNetBasedAppHostServerProject(appPath, "test.sock", appPath, runner, packagingService, new TestProcessExecutionFactory(), new TestEnvironment(), logger, projectModelPath);
var packages = new List<IntegrationReference>
{
IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"),
IntegrationReference.FromPackage("Aspire.Hosting.AppHost", "13.1.0"),
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0")
};
// Act
await project.CreateProjectFilesAsync(packages).DefaultTimeout();
// Dump workspace directory tree for debugging
outputHelper.WriteLine("=== Workspace Directory Tree ===");
DumpDirectoryTree(appPath, outputHelper);
outputHelper.WriteLine("================================");
// Also dump ProjectModelPath content
outputHelper.WriteLine($"=== ProjectModelPath ({project.ProjectModelPath}) ===");
if (Directory.Exists(project.ProjectModelPath))
{
DumpDirectoryTree(project.ProjectModelPath, outputHelper);
}
else
{
outputHelper.WriteLine(" (directory does not exist)");
}
outputHelper.WriteLine("================================");
// Assert - verify the csproj uses RestoreAdditionalProjectSources with the correct channel sources
var projectFilePath = Path.Combine(project.ProjectModelPath, DotNetBasedAppHostServerProject.ProjectFileName);
Assert.True(File.Exists(projectFilePath), $"Project file should be created at {projectFilePath}");
var projectContent = await File.ReadAllTextAsync(projectFilePath);
var projectDoc = XDocument.Parse(projectContent);
var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value;
// Should contain the pr-new hive path (project-local channel), NOT pr-old (global config)
Assert.NotNull(restoreSources);
Assert.Contains(prNewHive.FullName, restoreSources);
Assert.DoesNotContain(prOldHive.FullName, restoreSources);
}
[Fact]
public async Task CreateProjectFiles_WithPackageSourceOverride_PrependsOverrideToRestoreAdditionalProjectSources()
{
// Regression for finding #2 of the 2026-05-19 post-merge review: the DotNet-based
// (in-repo / dogfood) AppHost path previously declared a packageSourceOverride parameter
// on PrepareAsync but ignored it during restore, so `aspire new --source <pr-hive>` was
// silently dropped in dev mode. The override now threads through CreateProjectFilesAsync
// and prepends to the RestoreAdditionalProjectSources list so the dogfood hive is the
// first source NuGet evaluates for any PackageReference fallback in this path.
var appPath = _workspace.WorkspaceRoot.FullName;
const string overrideSource = "/tmp/aspire-pr-hive/packages";
var aspireConfigPath = Path.Combine(appPath, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "daily"
}
""");
var nugetCache = new FakeNuGetPackageCache();
var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, new[]
{
new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/fake/v3/index.json"),
new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
}, nugetCache, new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(new[] { dailyChannel })
};
var projectModelPath = Path.Combine(appPath, ".aspire_server");
var project = new DotNetBasedAppHostServerProject(
appPath,
"test.sock",
appPath,
new TestDotNetCliRunner(),
packagingService,
new TestProcessExecutionFactory(),
new TestEnvironment(),
NullLogger<DotNetBasedAppHostServerProject>.Instance,
projectModelPath);
var packages = new List<IntegrationReference>
{
IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
};
var (projectFilePath, _) = await project.CreateProjectFilesAsync(packages, packageSourceOverride: overrideSource, cancellationToken: CancellationToken.None).DefaultTimeout();
var projectDoc = XDocument.Load(projectFilePath);
var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value;
Assert.NotNull(restoreSources);
var sources = restoreSources!.Split(';');
// Override is prepended so the hive wins NuGet's source evaluation order when the same
// Aspire package version exists in both the hive and the channel feed.
Assert.Equal(overrideSource, sources[0]);
Assert.Contains("https://pkgs.dev.azure.com/fake/v3/index.json", sources);
}
[Fact]
public async Task CreateProjectFiles_WithoutPackageSourceOverride_DoesNotInjectExtraSource()
{
// Negative companion to the override regression: ensure the no-override path still emits
// only the channel sources (i.e., we are not accidentally introducing an empty/null
// source that breaks restore on the existing in-repo flow).
var appPath = _workspace.WorkspaceRoot.FullName;
var aspireConfigPath = Path.Combine(appPath, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "daily"
}
""");
var nugetCache = new FakeNuGetPackageCache();
const string channelFeed = "https://pkgs.dev.azure.com/fake/v3/index.json";
var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, new[]
{
new PackageMapping("Aspire*", channelFeed)
}, nugetCache, new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(new[] { dailyChannel })
};
var projectModelPath = Path.Combine(appPath, ".aspire_server");
var project = new DotNetBasedAppHostServerProject(
appPath,
"test.sock",
appPath,
new TestDotNetCliRunner(),
packagingService,
new TestProcessExecutionFactory(),
new TestEnvironment(),
NullLogger<DotNetBasedAppHostServerProject>.Instance,
projectModelPath);
var packages = new List<IntegrationReference>
{
IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
};
var (projectFilePath, _) = await project.CreateProjectFilesAsync(packages).DefaultTimeout();
var projectDoc = XDocument.Load(projectFilePath);
var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value;
Assert.NotNull(restoreSources);
Assert.Equal(channelFeed, restoreSources);
}
private static void DumpDirectoryTree(string path, ITestOutputHelper output, string indent = "")
{
var dirInfo = new DirectoryInfo(path);
output.WriteLine($"{indent}{dirInfo.Name}/");
foreach (var file in dirInfo.GetFiles())
{
output.WriteLine($"{indent} {file.Name}");
}
foreach (var dir in dirInfo.GetDirectories())
{
DumpDirectoryTree(dir.FullName, output, indent + " ");
}
}
}