// 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.IO.Compression;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using Aspire.Hosting.Tests.Utils;
using Aspire.Shared;
namespace Aspire.Hosting.Tests;
[Trait("Partition", "6")]
public class MSBuildTests(ITestOutputHelper outputHelper)
{
/// <summary>
/// Tests that when an AppHost has a ProjectReference to a library project, a warning is emitted.
/// </summary>
[Fact]
public void EnsureWarningsAreEmittedWhenProjectReferencingLibraries()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
CreateLibraryProject(workspace.WorkspaceRoot.FullName, "Library");
var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
Directory.CreateDirectory(appHostDirectory);
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<!--
Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
the Aspire.AppHost.SDK targets that will automatically add these references to projects.
-->
<SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
<AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
<_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\Library\Library.csproj" />
</ItemGroup>
</Project>
""");
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
"""
var builder = DistributedApplication.CreateBuilder();
builder.Build().Run();
""");
CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProject(appHostDirectory);
// Ensure a warning is emitted when an AppHost references a Library project
Assert.Contains("warning ASPIRE004", output);
}
/// <summary>
/// Tests that the metadata sources are emitted correctly.
/// </summary>
[Fact]
public async Task ValidateMetadataSources()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
CreateAppProject(workspace.WorkspaceRoot.FullName, "App");
var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
Directory.CreateDirectory(appHostDirectory);
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<!--
Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
the Aspire.AppHost.SDK targets that will automatically add these references to projects.
-->
<SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
<AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
<_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\App\App.csproj" />
</ItemGroup>
</Project>
""");
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
"""
var builder = DistributedApplication.CreateBuilder();
builder.Build().Run();
""");
CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProject(appHostDirectory);
var metadataDirectory = Path.Combine(appHostDirectory, "obj", "Debug", "net8.0", "Aspire", "references");
var appHostMetadata = await File.ReadAllTextAsync(Path.Combine(metadataDirectory, "_AppHost.ProjectMetadata.g.cs"));
var appMetadata = await File.ReadAllTextAsync(Path.Combine(metadataDirectory, "App.ProjectMetadata.g.cs"));
await Verify(new
{
AppHost = appHostMetadata,
App = appMetadata
}).ScrubLinesWithReplace(line =>
{
var temp = workspace?.WorkspaceRoot.FullName;
if (temp is not null)
{
line = line.Replace($"/private{temp}", "{AspirePath}") // Handle macOS temp symlinks
.Replace(temp, "{AspirePath}")
.Replace(Path.DirectorySeparatorChar, '/');
}
return line;
});
}
private static void CreateDirectoryBuildFiles(string basePath, string repoRoot)
{
#if DEBUG
var config = "Debug";
#else
var config = "Release";
#endif
File.WriteAllText(Path.Combine(basePath, "Directory.Build.props"),
$"""
<Project>
<PropertyGroup>
<SkipAspireWorkloadManifest>true</SkipAspireWorkloadManifest>
<AspireUseCliBundle>false</AspireUseCliBundle>
<NoWarn>$(NoWarn);ASPIRE010</NoWarn>
</PropertyGroup>
<Import Project="{repoRoot}\src\Aspire.Hosting.AppHost\build\Aspire.Hosting.AppHost.props" />
</Project>
""");
File.WriteAllText(Path.Combine(basePath, "Directory.Build.targets"),
$"""
<Project>
<PropertyGroup>
<_AspireTasksAssembly>{repoRoot}\artifacts\bin\Aspire.Hosting.Tasks\{config}\net8.0\Aspire.Hosting.Tasks.dll</_AspireTasksAssembly>
</PropertyGroup>
<Import Project="{repoRoot}\src\Aspire.Hosting.AppHost\build\Aspire.Hosting.AppHost.in.targets" />
<Import Project="{repoRoot}\src\Aspire.AppHost.Sdk\SDK\Sdk.in.targets" />
</Project>
""");
}
private static void CreateLibraryProject(string basePath, string name)
{
var libraryDirectory = Path.Combine(basePath, name);
Directory.CreateDirectory(libraryDirectory);
File.WriteAllText(Path.Combine(libraryDirectory, $"{name}.csproj"),
"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
""");
File.WriteAllText(Path.Combine(libraryDirectory, "Class1.cs"),
"""
namespace Library;
public class Class1
{
}
""");
}
private static void CreateAppProject(string basePath, string name)
{
var appDirectory = Path.Combine(basePath, name);
Directory.CreateDirectory(appDirectory);
File.WriteAllText(Path.Combine(appDirectory, $"{name}.csproj"),
"""
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
""");
File.WriteAllText(Path.Combine(appDirectory, "Program.cs"),
"""
Console.WriteLine("Hello, Aspire!");
""");
}
private static string BuildProject(string workingDirectory, IDictionary<string, string>? environment = null)
{
var result = BuildProjectCore(workingDirectory, environment);
Assert.True(result.ExitCode == 0, $"Build failed: {Environment.NewLine}{result.Output}");
return result.Output;
}
private static string BuildProjectWithFailure(string workingDirectory, IDictionary<string, string>? environment = null)
{
var result = BuildProjectCore(workingDirectory, environment);
Assert.NotEqual(0, result.ExitCode);
return result.Output;
}
private static (int ExitCode, string Output) BuildProjectCore(string workingDirectory, IDictionary<string, string>? environment = null)
{
return RunDotNet(workingDirectory, "build --disable-build-servers", timeoutMilliseconds: 180_000, environment);
}
private static string PackProject(string projectFile, string outputDirectory, string packageId)
{
var workingDirectory = Path.GetDirectoryName(projectFile);
Assert.NotNull(workingDirectory);
var result = RunDotNet(
workingDirectory,
$"pack \"{projectFile}\" --disable-build-servers -o \"{outputDirectory}\"",
timeoutMilliseconds: 300_000);
Assert.True(result.ExitCode == 0, $"Pack failed: {Environment.NewLine}{result.Output}");
return Directory.GetFiles(outputDirectory, $"{packageId}.*.nupkg")
.Single(path => !path.EndsWith(".snupkg", StringComparison.Ordinal));
}
private static string ReadPackageVersion(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var nuspecEntry = archive.Entries.Single(entry => entry.FullName.EndsWith(".nuspec", StringComparison.Ordinal));
using var stream = nuspecEntry.Open();
var document = XDocument.Load(stream);
var version = document.Root?
.Descendants()
.FirstOrDefault(element => element.Name.LocalName == "version")?
.Value;
Assert.False(string.IsNullOrEmpty(version), $"Could not determine package version from {packagePath}.");
return version!;
}
private static (int ExitCode, string Output) RunDotNet(string workingDirectory, string arguments, int timeoutMilliseconds, IDictionary<string, string>? environment = null)
{
var output = new StringBuilder();
var outputDone = new ManualResetEvent(false);
using var process = new Process();
// set '--disable-build-servers' so the MSBuild and Roslyn server processes don't hang around, which may hang the test in CI
process.StartInfo = new ProcessStartInfo("dotnet", arguments)
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = workingDirectory
};
if (environment is not null)
{
foreach (var (key, value) in environment)
{
process.StartInfo.Environment[key] = value;
}
}
process.OutputDataReceived += (sender, e) =>
{
if (e.Data == null)
{
outputDone.Set();
}
else
{
output.AppendLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
Assert.True(process.WaitForExit(milliseconds: timeoutMilliseconds), $"dotnet {arguments} command timed out.");
Assert.True(outputDone.WaitOne(millisecondsTimeout: 60_000), "Timed out waiting for output to complete.");
return (process.ExitCode, output.ToString());
}
[Fact]
public void AppHostInspectionQuerySucceedsForPlainAppHostNamedProject()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var projectPath = Path.Combine(workspace.WorkspaceRoot.FullName, "Plain.AppHost.csproj");
File.WriteAllText(projectPath, """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
""");
var result = RunDotNet(
workspace.WorkspaceRoot.FullName,
$"msbuild -getProperty:MSBuildVersion,IsAspireHost,AspireHostingSDKVersion,AspireUseCliBundle,UserSecretsId,RunCommand,TargetPath,RunWorkingDirectory,RunArguments,TargetFramework,TargetFrameworks -getItem:PackageReference,AspireProjectOrPackageReference,PackageVersion -t:ComputeRunArguments \"{projectPath}\"",
timeoutMilliseconds: 180_000,
new Dictionary<string, string>
{
["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "1",
});
Assert.True(result.ExitCode == 0, $"MSBuild query failed: {Environment.NewLine}{result.Output}");
using var document = JsonDocument.Parse(result.Output);
Assert.Equal(string.Empty, document.RootElement.GetProperty("Properties").GetProperty("IsAspireHost").GetString());
}
[Fact]
public async Task CliBundleOptInResolvesExplicitBundlePath()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var bundle = CreateFakeCliBundle(workspace.WorkspaceRoot.FullName);
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
$"""
<AspireCliBundlePath>{bundle.LayoutRoot}</AspireCliBundlePath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory);
var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
Assert.Equal(bundle.DcpDir, resolvedPaths[0]);
Assert.Equal(bundle.ManagedDir, resolvedPaths[1]);
Assert.Equal(bundle.ManagedPath, resolvedPaths[2]);
}
[Fact]
public async Task CliBundleOptInResolvesNewestVersionedBundlePath()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var layoutRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "layout");
_ = CreateFakeCliBundleAtVersionedLayoutRoot(layoutRoot, "13.9.0-preview.1.25301.1_older-aaaaaaaaaaaaaaaa");
var newestBundle = CreateFakeCliBundleAtVersionedLayoutRoot(layoutRoot, "13.10.0-preview.1.25301.1_newer-bbbbbbbbbbbbbbbb");
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
$"""
<AspireCliBundlePath>{layoutRoot}</AspireCliBundlePath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory);
var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
Assert.Equal(newestBundle.DcpDir, resolvedPaths[0]);
Assert.Equal(newestBundle.ManagedDir, resolvedPaths[1]);
Assert.Equal(newestBundle.ManagedPath, resolvedPaths[2]);
}
[Fact]
public async Task CliBundleOptInIgnoresTemporaryVersionedBundleDirectories()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var layoutRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "layout");
_ = CreateFakeCliBundleAtVersionedLayoutRoot(layoutRoot, "99.0.0-preview.1.tmp.aaaaaaaaaaaaaaaa");
var newestBundle = CreateFakeCliBundleAtVersionedLayoutRoot(layoutRoot, "13.10.0-preview.1.25301.1_newer-bbbbbbbbbbbbbbbb");
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
$"""
<AspireCliBundlePath>{layoutRoot}</AspireCliBundlePath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory);
var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
Assert.Equal(newestBundle.DcpDir, resolvedPaths[0]);
Assert.Equal(newestBundle.ManagedDir, resolvedPaths[1]);
Assert.Equal(newestBundle.ManagedPath, resolvedPaths[2]);
}
[Fact]
public void CliBundleDefaultEmitsWarning()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostDirectory = CreateSdkBundleAppHostProject(
workspace.WorkspaceRoot.FullName,
repoRoot,
additionalProperties: "",
optIntoCliBundle: false);
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProject(appHostDirectory);
Assert.Contains("warning ASPIRE010", output);
Assert.Contains("Some Aspire features require the Aspire CLI bundle", output);
Assert.Contains("Set AspireUseCliBundle=true", output);
Assert.Contains("suppress ASPIRE010", output);
}
[Fact]
public async Task CliBundleOptInResolvesAspireHomeBundleForSidecarlessCli()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "bin"));
var fakeCliPath = CreateFakeAspireCli(fakeCliDirectory.FullName);
var bundle = CreateFakeCliBundle(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-home"));
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
$"""
<AspireCliPath>{fakeCliPath}</AspireCliPath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory, new Dictionary<string, string> { ["ASPIRE_HOME"] = bundle.LayoutRoot });
var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
Assert.Equal(bundle.DcpDir, resolvedPaths[0]);
Assert.Equal(bundle.ManagedDir, resolvedPaths[1]);
Assert.Equal(bundle.ManagedPath, resolvedPaths[2]);
}
[Fact]
public async Task CliBundleOptInPreparesMissingBundleBesidePathCli()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
_ = CreateFakeAspireCliThatSetsUpBundle(fakeCliDirectory.FullName);
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot, additionalProperties: "");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory, new Dictionary<string, string>
{
["ASPIRE_HOME"] = aspireHome,
["PATH"] = GetPathWithoutAspire(fakeCliDirectory.FullName)
});
var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
var bundleRoot = Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName);
var expectedDcpDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "dcp"));
var expectedManagedDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "managed"));
Assert.Equal(expectedDcpDir, resolvedPaths[0]);
Assert.Equal(expectedManagedDir, resolvedPaths[1]);
Assert.Equal(Path.Combine(expectedManagedDir, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed"), resolvedPaths[2]);
Assert.False(Directory.Exists(Path.Combine(aspireHome, BundleDiscovery.BundleDirectoryName)));
}
[Fact]
public void CliBundleOptInSkipsSetupDuringDesignTimeBuild()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
var fakeCliPath = CreateFakeAspireCliThatSetsUpBundle(fakeCliDirectory.FullName);
var appHostDirectory = CreateSdkBundleAppHostProject(
workspace.WorkspaceRoot.FullName,
repoRoot,
$"""
<AspireCliPath>{fakeCliPath}</AspireCliPath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
var result = RunDotNet(
appHostDirectory,
"build --disable-build-servers -p:DesignTimeBuild=true",
timeoutMilliseconds: 180_000,
new Dictionary<string, string> { ["ASPIRE_HOME"] = aspireHome });
Assert.True(result.ExitCode == 0, $"Design-time build failed: {Environment.NewLine}{result.Output}");
Assert.False(Directory.Exists(Path.Combine(aspireHome, BundleDiscovery.BundleDirectoryName)));
Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName)));
}
[Fact]
public void CliBundleSetupFailureReportsAspire009WhenWarningsAreErrors()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
var fakeCliPath = CreateFakeAspireCliThatFailsSetup(fakeCliDirectory.FullName);
var appHostDirectory = CreateSdkBundleAppHostProject(
workspace.WorkspaceRoot.FullName,
repoRoot,
$"""
<AspireCliPath>{fakeCliPath}</AspireCliPath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
var result = RunDotNet(
appHostDirectory,
"build --disable-build-servers -warnaserror",
timeoutMilliseconds: 180_000,
new Dictionary<string, string> { ["ASPIRE_HOME"] = aspireHome });
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("ASPIRE009", result.Output);
Assert.Contains("failed with exit code 42", result.Output);
Assert.Contains($"Run '\"{fakeCliPath}\" setup'", result.Output);
}
[Fact]
public void CliBundleSetupTimeoutReportsAspire009()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
var fakeCliPath = CreateFakeAspireCliThatTimesOutSetup(fakeCliDirectory.FullName);
var appHostDirectory = CreateSdkBundleAppHostProject(
workspace.WorkspaceRoot.FullName,
repoRoot,
$"""
<AspireCliPath>{fakeCliPath}</AspireCliPath>
<_AspireCliBundleSetupTimeout>100</_AspireCliBundleSetupTimeout>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProjectWithFailure(
appHostDirectory,
new Dictionary<string, string> { ["ASPIRE_HOME"] = aspireHome });
Assert.Contains("ASPIRE009", output);
Assert.Contains("Automatic Aspire CLI bundle setup did not produce a usable DCP and dashboard layout.", output);
Assert.Contains("The command timed out", output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CliBundleOptInPreservesLiteralPercentCharactersInWindowsCommandShimPath()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows command-shim escaping is required.");
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home");
var literalSegment = "%ASPIRE_LITERAL_SEGMENT%";
var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, literalSegment));
var fakeCliPath = CreateFakeAspireCliThatSetsUpBundle(fakeCliDirectory.FullName);
var appHostDirectory = CreateSdkBundleAppHostProject(
workspace.WorkspaceRoot.FullName,
repoRoot,
$"""
<AspireCliPath>{fakeCliPath}</AspireCliPath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory, new Dictionary<string, string>
{
["ASPIRE_HOME"] = aspireHome,
["ASPIRE_LITERAL_SEGMENT"] = "expanded-segment"
});
var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
var bundleRoot = Path.Combine(workspace.WorkspaceRoot.FullName, BundleDiscovery.BundleDirectoryName);
Assert.Equal(EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.DcpDirectoryName)), resolvedPaths[0]);
Assert.Equal(EnsureTrailingSeparator(Path.Combine(bundleRoot, BundleDiscovery.ManagedDirectoryName)), resolvedPaths[1]);
}
[Fact]
public void CliBundleSetupFailureDiagnosticPreservesLiteralPercentCharacters()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows command-shim escaping is required.");
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var literalSegment = "%ASPIRE_LITERAL_SEGMENT%";
var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, literalSegment));
var fakeCliPath = CreateFakeAspireCliThatFailsSetup(fakeCliDirectory.FullName);
var appHostDirectory = CreateSdkBundleAppHostProject(
workspace.WorkspaceRoot.FullName,
repoRoot,
$"""
<AspireCliPath>{fakeCliPath}</AspireCliPath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProjectWithFailure(appHostDirectory, new Dictionary<string, string>
{
["ASPIRE_HOME"] = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"),
["ASPIRE_LITERAL_SEGMENT"] = "expanded-segment"
});
Assert.Contains($"Run '\"{fakeCliPath}\" setup'", output);
Assert.DoesNotContain("^^%%", output);
Assert.DoesNotContain("cmd /D /V:OFF", output);
}
[Fact]
public async Task CliBundleOptInResolvesAspireHomeBundleWithoutCliOnPath()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var bundle = CreateFakeCliBundle(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-home"));
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot, additionalProperties: "");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory, new Dictionary<string, string>
{
["ASPIRE_HOME"] = bundle.LayoutRoot,
["PATH"] = GetDotNetOnlyPath()
});
var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
Assert.Equal(bundle.DcpDir, resolvedPaths[0]);
Assert.Equal(bundle.ManagedDir, resolvedPaths[1]);
Assert.Equal(bundle.ManagedPath, resolvedPaths[2]);
}
[Fact]
public async Task CliBundleOptInResolvesDotNetToolStoreBundleFromShimPath()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var toolsDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".dotnet", "tools"));
var shimPath = CreateFakeAspireCli(toolsDirectory.FullName);
var nativeCliDirectory = Directory.CreateDirectory(Path.Combine(
toolsDirectory.FullName,
".store",
"aspire.cli",
"13.4.0",
OperatingSystem.IsWindows() ? "aspire.cli.win-x64" : "aspire.cli.linux-x64",
"13.4.0",
"tools",
"net10.0",
OperatingSystem.IsWindows() ? "win-x64" : "linux-x64"));
_ = CreateFakeAspireCli(nativeCliDirectory.FullName);
File.WriteAllText(Path.Combine(nativeCliDirectory.FullName, ".aspire-install.json"), """{"source":"dotnet-tool"}""");
var bundle = CreateFakeCliBundleAtLayoutRoot(nativeCliDirectory.FullName);
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
$"""
<AspireCliPath>{shimPath}</AspireCliPath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory);
var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
Assert.Equal(bundle.DcpDir, resolvedPaths[0]);
Assert.Equal(bundle.ManagedDir, resolvedPaths[1]);
Assert.Equal(bundle.ManagedPath, resolvedPaths[2]);
}
[Fact]
public async Task CliBundleOptInPrefersExistingRepoPathsOverBundlePath()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var bundle = CreateFakeCliBundle(workspace.WorkspaceRoot.FullName);
var repoDcpDir = EnsureTrailingSeparator(Path.Combine(workspace.WorkspaceRoot.FullName, "repo-dcp"));
var repoDashboardDir = EnsureTrailingSeparator(Path.Combine(workspace.WorkspaceRoot.FullName, "repo-dashboard"));
Directory.CreateDirectory(repoDcpDir);
Directory.CreateDirectory(repoDashboardDir);
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
$"""
<DcpDir>{repoDcpDir}</DcpDir>
<AspireDashboardDir>{repoDashboardDir}</AspireDashboardDir>
<AspireCliBundlePath>{bundle.LayoutRoot}</AspireCliBundlePath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory);
var resolvedPaths = await File.ReadAllLinesAsync(Path.Combine(appHostDirectory, "obj", "resolved-aspire-paths.txt"));
Assert.Equal(repoDcpDir, resolvedPaths[0]);
Assert.Equal(repoDashboardDir, resolvedPaths[1]);
Assert.Contains(Path.Combine(repoDashboardDir, "Aspire.Dashboard"), resolvedPaths[2]);
}
[Fact]
public void CliBundleOptInFailsWhenExplicitBundlePathIsInvalid()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var missingBundlePath = Path.Combine(workspace.WorkspaceRoot.FullName, "missing-bundle");
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
$"""
<AspireCliInvocationMode>Dnx</AspireCliInvocationMode>
<AspireCliBundlePath>{missingBundlePath}</AspireCliBundlePath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProjectWithFailure(appHostDirectory);
Assert.Contains("warning", output);
Assert.Contains("AspireCliBundlePath", output);
Assert.Contains(missingBundlePath, output);
Assert.Contains("ASPIRE009", output);
Assert.Contains("the bundle could not be resolved", output);
Assert.Contains("New features require the Aspire CLI to be installed.", output);
Assert.Contains("https://get.aspire.dev", output);
Assert.DoesNotContain("DCP path could not be resolved", output);
}
[Theory]
[InlineData(null)]
[InlineData("Dnx")]
public void CliBundleDnxInvocationFailsWhenSetupCannotProduceLayout(string? invocationMode)
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var additionalProperties = invocationMode is null
? ""
: $"""
<AspireCliInvocationMode>{invocationMode}</AspireCliInvocationMode>
""";
var appHostDirectory = CreateSdkBundleAppHostProject(
workspace.WorkspaceRoot.FullName,
repoRoot,
additionalProperties);
var fakeCliDirectory = Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, "fake-cli"));
var dnxPath = Path.Combine(fakeCliDirectory.FullName, OperatingSystem.IsWindows() ? "dnx.exe" : "dnx");
if (OperatingSystem.IsWindows())
{
// Use a terminating PE executable. A copied cmd.exe starts an interactive shell
// because the DNX arguments do not include /C and leaves the test waiting for timeout.
File.Copy(Path.Combine(Environment.SystemDirectory, "where.exe"), dnxPath);
}
else
{
File.WriteAllText(dnxPath, "#!/bin/sh\nexit 42\n");
File.SetUnixFileMode(dnxPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProjectWithFailure(appHostDirectory, new Dictionary<string, string>
{
["ASPIRE_HOME"] = Path.Combine(workspace.WorkspaceRoot.FullName, "empty-aspire-home"),
["PATH"] = GetPathWithoutAspire(fakeCliDirectory.FullName)
});
Assert.Contains("ASPIRE009", output);
Assert.Contains("the bundle could not be resolved", output);
Assert.Contains("failed with exit code", output);
if (!OperatingSystem.IsWindows())
{
Assert.Contains("failed with exit code 42", output);
}
}
[Fact]
public void CliBundleOptInFailsWhenExplicitCliPathIsInvalid()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var missingCliPath = Path.Combine(workspace.WorkspaceRoot.FullName, "missing-aspire");
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
$"""
<AspireCliPath>{missingCliPath}</AspireCliPath>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProjectWithFailure(appHostDirectory);
Assert.Contains("warning", output);
Assert.Contains("AspireCliPath", output);
Assert.Contains(missingCliPath, output);
Assert.Contains("ASPIRE009", output);
}
[Fact]
public async Task CliBundleDefaultsToFalseForNonCSharpAppHostProject()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
Directory.CreateDirectory(appHostDirectory);
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.fsproj"),
"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>
<Target Name="WriteAspireUseCliBundleProperty">
<WriteLinesToFile File="$(BaseIntermediateOutputPath)aspire-use-cli-bundle.txt"
Lines="Value=$(AspireUseCliBundle)"
Overwrite="true" />
</Target>
</Project>
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
var result = RunDotNet(appHostDirectory, "msbuild AppHost.fsproj /t:WriteAspireUseCliBundleProperty /v:minimal", timeoutMilliseconds: 180_000);
Assert.Equal(0, result.ExitCode);
var property = await File.ReadAllTextAsync(Path.Combine(appHostDirectory, "obj", "aspire-use-cli-bundle.txt"));
Assert.Equal("Value=false", property.Trim());
}
[Fact]
public async Task CliBundleOptInKeepsSdkProjectReferenceMutation()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
CreateAppProject(workspace.WorkspaceRoot.FullName, "App");
var bundle = CreateFakeCliBundle(workspace.WorkspaceRoot.FullName);
var appHostDirectory = CreateSdkBundleAppHostProject(workspace.WorkspaceRoot.FullName, repoRoot,
$"""
<AspireCliBundlePath>{bundle.LayoutRoot}</AspireCliBundlePath>
""",
"""
<ProjectReference Include="..\App\App.csproj" />
""");
CreateAppHostPackageDirectoryBuildFiles(appHostDirectory, repoRoot);
BuildProject(appHostDirectory);
var metadataPath = Path.Combine(appHostDirectory, "obj", "Debug", "net8.0", "Aspire", "references", "App.ProjectMetadata.g.cs");
var appMetadata = await File.ReadAllTextAsync(metadataPath);
Assert.Contains("class App : global::Aspire.Hosting.IProjectMetadata", appMetadata);
}
private static string CreateSdkBundleAppHostProject(
string basePath,
string repoRoot,
string additionalProperties,
string additionalProjectReferences = "",
bool optIntoCliBundle = true)
{
var appHostDirectory = Path.Combine(basePath, "AppHost");
Directory.CreateDirectory(appHostDirectory);
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
<SkipAddAspireDefaultReferences>true</SkipAddAspireDefaultReferences>
<_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
{(optIntoCliBundle ? "<AspireUseCliBundle>true</AspireUseCliBundle>" : "")}
{additionalProperties}
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
{additionalProjectReferences}
</ItemGroup>
<Target Name="WriteResolvedAspirePaths" AfterTargets="GetAssemblyAttributes">
<ItemGroup>
<_AppHostIdentityMetadata Include="@(AssemblyAttribute)"
Condition="'%(AssemblyAttribute._Parameter1)' == 'apphostprojectpath' or '%(AssemblyAttribute._Parameter1)' == 'apphostprojectname'" />
</ItemGroup>
<WriteLinesToFile File="$(BaseIntermediateOutputPath)resolved-aspire-paths.txt"
Lines="$(DcpDir);$(AspireDashboardDir);$(AspireDashboardPath)"
Overwrite="true" />
<WriteLinesToFile File="$(BaseIntermediateOutputPath)apphost-identity-metadata.txt"
Lines="@(_AppHostIdentityMetadata->'%(_Parameter1)=%(_Parameter2)')"
Overwrite="true" />
</Target>
</Project>
""");
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
"""
var builder = DistributedApplication.CreateBuilder();
builder.Build().Run();
""");
return appHostDirectory;
}
private static void CreateAppHostPackageDirectoryBuildFiles(string basePath, string repoRoot)
{
#if DEBUG
var config = "Debug";
#else
var config = "Release";
#endif
File.WriteAllText(Path.Combine(basePath, "Directory.Build.props"),
$"""
<Project>
<PropertyGroup>
<SkipAspireWorkloadManifest>true</SkipAspireWorkloadManifest>
</PropertyGroup>
<Import Project="{repoRoot}\src\Aspire.Hosting.AppHost\build\Aspire.Hosting.AppHost.props" />
</Project>
""");
File.WriteAllText(Path.Combine(basePath, "Directory.Build.targets"),
$"""
<Project>
<PropertyGroup>
<_AspireTasksAssembly>{repoRoot}\artifacts\bin\Aspire.Hosting.Tasks\{config}\net8.0\Aspire.Hosting.Tasks.dll</_AspireTasksAssembly>
</PropertyGroup>
<Import Project="{repoRoot}\src\Aspire.Hosting.AppHost\build\Aspire.Hosting.AppHost.in.targets" />
<Import Project="{repoRoot}\src\Aspire.AppHost.Sdk\SDK\Sdk.in.targets" />
</Project>
""");
}
private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundle(string basePath)
{
return CreateFakeCliBundleAtLayoutRoot(Path.Combine(basePath, "layout"));
}
private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundleAtLayoutRoot(string layoutRoot)
{
return CreateFakeCliBundleRoot(layoutRoot, Path.Combine(layoutRoot, "bundle"));
}
private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundleAtVersionedLayoutRoot(string layoutRoot, string versionId)
{
return CreateFakeCliBundleRoot(layoutRoot, Path.Combine(layoutRoot, "versions", versionId));
}
private static (string LayoutRoot, string DcpDir, string ManagedDir, string ManagedPath) CreateFakeCliBundleRoot(string layoutRoot, string bundleRoot)
{
var dcpDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "dcp"));
var managedDir = EnsureTrailingSeparator(Path.Combine(bundleRoot, "managed"));
Directory.CreateDirectory(dcpDir);
Directory.CreateDirectory(managedDir);
File.WriteAllText(Path.Combine(dcpDir, OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"), "");
var managedPath = Path.Combine(managedDir, OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed");
File.WriteAllText(managedPath, "");
return (layoutRoot, dcpDir, managedDir, managedPath);
}
private static string CreateFakeAspireCli(string directory)
{
var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire");
File.WriteAllText(cliPath, "");
return cliPath;
}
private static string CreateFakeAspireCliThatSetsUpBundle(string directory)
{
var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
var dcpExecutable = OperatingSystem.IsWindows() ? "dcp.exe" : "dcp";
var managedExecutable = OperatingSystem.IsWindows() ? "aspire-managed.exe" : "aspire-managed";
var contents = OperatingSystem.IsWindows()
? $$"""
@echo off
if not "%~1"=="setup" exit /b 2
mkdir "%~dp0..\bundle\dcp"
mkdir "%~dp0..\bundle\managed"
type nul > "%~dp0..\bundle\dcp\{{dcpExecutable}}"
type nul > "%~dp0..\bundle\managed\{{managedExecutable}}"
"""
: $$"""
#!/bin/sh
if [ "$1" != "setup" ]; then
exit 2
fi
install_path="$(dirname "$0")/.."
mkdir -p "$install_path/bundle/dcp" "$install_path/bundle/managed"
: > "$install_path/bundle/dcp/{{dcpExecutable}}"
: > "$install_path/bundle/managed/{{managedExecutable}}"
""";
File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n"));
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
return cliPath;
}
private static string CreateFakeAspireCliThatTimesOutSetup(string directory)
{
var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
var contents = OperatingSystem.IsWindows()
? """
@echo off
if not "%~1"=="setup" exit /b 2
ping -n 6 127.0.0.1 > nul
"""
: """
#!/bin/sh
if [ "$1" != "setup" ]; then
exit 2
fi
sleep 5
""";
File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n"));
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
return cliPath;
}
private static string CreateFakeAspireCliThatFailsSetup(string directory)
{
var cliPath = Path.Combine(directory, OperatingSystem.IsWindows() ? "aspire.cmd" : "aspire");
var contents = OperatingSystem.IsWindows()
? """
@echo off
if not "%~1"=="setup" exit /b 2
exit /b 42
"""
: """
#!/bin/sh
if [ "$1" != "setup" ]; then
exit 2
fi
exit 42
""";
File.WriteAllText(cliPath, contents.ReplaceLineEndings(OperatingSystem.IsWindows() ? "\r\n" : "\n"));
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(cliPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
return cliPath;
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar;
}
private static string GetDotNetOnlyPath()
{
var dotnetPath = DotnetFileAppProcess.ResolvedExecutablePath;
var dotnetDirectory = Path.GetDirectoryName(dotnetPath);
Assert.False(string.IsNullOrEmpty(dotnetDirectory), $"Could not determine the directory for dotnet path '{dotnetPath}'.");
return dotnetDirectory;
}
private static string GetPathWithoutAspire(string firstDirectory)
{
var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
var pathDirectories = currentPath
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
.Where(directory => !ContainsCommand(directory, "aspire"));
return string.Join(Path.PathSeparator, pathDirectories.Prepend(firstDirectory));
}
private static bool ContainsCommand(string directory, string commandName)
{
var executableNames = OperatingSystem.IsWindows()
? new[] { $"{commandName}.exe", $"{commandName}.cmd", $"{commandName}.bat", commandName }
: [commandName];
return executableNames.Any(executableName => File.Exists(Path.Combine(directory.Trim().Trim('"'), executableName)));
}
/// <summary>
/// Tests that when TreatProjectReferencesAsResources is set to false,
/// ProjectReference items are not mutated with Aspire-specific metadata.
/// </summary>
[Fact]
public void TreatProjectReferencesAsResourcesFalse_DisablesMutation()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
CreateLibraryProject(workspace.WorkspaceRoot.FullName, "Library");
var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
Directory.CreateDirectory(appHostDirectory);
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<TreatProjectReferencesAsResources>false</TreatProjectReferencesAsResources>
<!--
Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
the Aspire.AppHost.SDK targets that will automatically add these references to projects.
-->
<SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
<AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
<_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" />
<ProjectReference Include="..\Library\Library.csproj" />
</ItemGroup>
</Project>
""");
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
"""
var builder = DistributedApplication.CreateBuilder();
builder.Build().Run();
""");
CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProject(appHostDirectory);
// When TreatProjectReferencesAsResources is false, the Library project should be treated as a normal reference
// and no ASPIRE004 warning should be emitted since the references are not being mutated
Assert.DoesNotContain("warning ASPIRE004", output);
}
/// <summary>
/// Tests that when TreatProjectReferencesAsResources is explicitly set to true,
/// ProjectReference items are mutated with Aspire-specific metadata (same as default).
/// </summary>
[Fact]
public void TreatProjectReferencesAsResourcesTrue_EnablesMutation()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
CreateLibraryProject(workspace.WorkspaceRoot.FullName, "Library");
var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
Directory.CreateDirectory(appHostDirectory);
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<TreatProjectReferencesAsResources>true</TreatProjectReferencesAsResources>
<!--
Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
the Aspire.AppHost.SDK targets that will automatically add these references to projects.
-->
<SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
<AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
<_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
<ProjectReference Include="..\Library\Library.csproj" />
</ItemGroup>
</Project>
""");
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
"""
var builder = DistributedApplication.CreateBuilder();
builder.Build().Run();
""");
CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
var output = BuildProject(appHostDirectory);
// When TreatProjectReferencesAsResources is explicitly set to true, the mutation should happen
// and ASPIRE004 warning should be emitted for the Library project reference
Assert.Contains("warning ASPIRE004", output);
}
[Fact]
public void AspireExportAnalyzersAreDisabledByDefault()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "MyHostingExtension");
Directory.CreateDirectory(projectDirectory);
File.WriteAllText(Path.Combine(projectDirectory, "MyHostingExtension.csproj"),
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting\Aspire.Hosting.csproj" />
</ItemGroup>
</Project>
""");
File.WriteAllText(Path.Combine(projectDirectory, "Extensions.cs"),
"""
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
namespace MyHostingExtension;
public static class CustomResourceExtensions
{
public static IResourceBuilder<ContainerResource> AddCustomContainer(this IDistributedApplicationBuilder builder)
{
return builder.AddContainer("custom", "custom-image");
}
}
""");
CreateExportAnalyzerDirectoryBuildFiles(projectDirectory, repoRoot);
var output = BuildProject(projectDirectory);
Assert.DoesNotContain("warning ASPIREEXPORT008", output);
}
[Fact]
public void AspireExportAnalyzersCanBeEnabledWithMsBuildProperty()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "MyHostingExtension");
Directory.CreateDirectory(projectDirectory);
File.WriteAllText(Path.Combine(projectDirectory, "MyHostingExtension.csproj"),
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting\Aspire.Hosting.csproj" />
</ItemGroup>
</Project>
""");
File.WriteAllText(Path.Combine(projectDirectory, "Extensions.cs"),
"""
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
namespace MyHostingExtension;
public static class CustomResourceExtensions
{
public static IResourceBuilder<ContainerResource> AddCustomContainer(this IDistributedApplicationBuilder builder)
{
return builder.AddContainer("custom", "custom-image");
}
}
""");
CreateExportAnalyzerDirectoryBuildFiles(projectDirectory, repoRoot, enableAspireIntegrationAnalyzers: true);
var output = BuildProject(projectDirectory);
Assert.Contains("warning ASPIREEXPORT008", output);
}
[Fact]
public void AspireIntegrationAnalyzerPackageContainsExpectedAssets()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var packageOutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "packages");
Directory.CreateDirectory(packageOutputPath);
var packagePath = PackProject(
Path.Combine(repoRoot, "src", "Aspire.Hosting.Integration.Analyzers", "Aspire.Hosting.Integration.Analyzers.csproj"),
packageOutputPath,
"Aspire.Hosting.Integration.Analyzers");
using var archive = ZipFile.OpenRead(packagePath);
Assert.Contains(archive.Entries, entry => entry.FullName == "analyzers/dotnet/cs/Aspire.Hosting.Integration.Analyzers.dll");
Assert.Contains(archive.Entries, entry => entry.FullName == "README.md");
Assert.DoesNotContain(archive.Entries, entry => entry.FullName.StartsWith("lib/", StringComparison.Ordinal));
var nuspecEntry = archive.Entries.Single(entry => entry.FullName.EndsWith(".nuspec", StringComparison.Ordinal));
using var nuspecReader = new StreamReader(nuspecEntry.Open());
var nuspec = nuspecReader.ReadToEnd();
Assert.DoesNotContain("<dependency", nuspec, StringComparison.Ordinal);
}
[Fact]
public void AspireHostingBlazorPackageContainsScriptsForBuildAndDirectLoading()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var packageOutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "packages");
Directory.CreateDirectory(packageOutputPath);
var packagePath = PackProject(
Path.Combine(repoRoot, "src", "Aspire.Hosting.Blazor", "Aspire.Hosting.Blazor.csproj"),
packageOutputPath,
"Aspire.Hosting.Blazor");
using var archive = ZipFile.OpenRead(packagePath);
var assemblyEntry = Assert.Single(
archive.Entries,
entry => entry.FullName.StartsWith("lib/", StringComparison.Ordinal)
&& entry.Name == "Aspire.Hosting.Blazor.dll");
var targetFramework = assemblyEntry.FullName.Split('/')[1];
Assert.Contains(
archive.Entries,
entry => entry.FullName == $"buildTransitive/{targetFramework}/Aspire.Hosting.Blazor.targets");
foreach (var scriptName in new[] { "Gateway.cs", "PrefixEndpoints.cs" })
{
var buildTransitiveEntry = Assert.Single(
archive.Entries,
entry => entry.FullName == $"buildTransitive/{targetFramework}/Scripts/{scriptName}");
var directLoadEntry = Assert.Single(
archive.Entries,
entry => entry.FullName == $"lib/{targetFramework}/Scripts/{scriptName}");
using var buildTransitiveStream = buildTransitiveEntry.Open();
using var directLoadStream = directLoadEntry.Open();
using var buildTransitiveContent = new MemoryStream();
using var directLoadContent = new MemoryStream();
buildTransitiveStream.CopyTo(buildTransitiveContent);
directLoadStream.CopyTo(directLoadContent);
Assert.Equal(buildTransitiveContent.ToArray(), directLoadContent.ToArray());
}
}
[Fact]
public void AspireIntegrationAnalyzerPackageCanBeConsumedFromLocalSource()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var packageOutputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "packages");
Directory.CreateDirectory(packageOutputPath);
var analyzerPackagePath = PackProject(
Path.Combine(repoRoot, "src", "Aspire.Hosting.Integration.Analyzers", "Aspire.Hosting.Integration.Analyzers.csproj"),
packageOutputPath,
"Aspire.Hosting.Integration.Analyzers");
var analyzerVersion = ReadPackageVersion(analyzerPackagePath);
var projectDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "MyHostingExtension");
Directory.CreateDirectory(projectDirectory);
var nuGetConfigPath = Path.Combine(projectDirectory, "NuGet.config");
File.WriteAllText(nuGetConfigPath,
$"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="local" value="{packageOutputPath}" />
<add key="dotnet-public" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json" />
<add key="dotnet-eng" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json" />
<add key="dotnet9" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
<add key="dotnet10" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json" />
<add key="dotnet-libraries" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-libraries/nuget/v3/index.json" />
<add key="dotnet9-transport" value="https://dnceng.pkgs.visualstudio.com/public/_packaging/dotnet9-transport/nuget/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="local">
<package pattern="Aspire.Hosting.Integration.Analyzers" />
</packageSource>
<packageSource key="dotnet9-transport">
<package pattern="*WorkloadBuildTasks*" />
</packageSource>
<packageSource key="dotnet-public">
<package pattern="*" />
<package pattern="Microsoft.FluentUI.AspNetCore.Components" />
<package pattern="Microsoft.FluentUI.AspNetCore.Components.Icons" />
</packageSource>
<packageSource key="dotnet9">
<package pattern="*" />
</packageSource>
<packageSource key="dotnet10">
<package pattern="*" />
</packageSource>
<packageSource key="dotnet-libraries">
<package pattern="Microsoft.DeveloperControlPlane*" />
</packageSource>
<packageSource key="dotnet-eng">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
File.WriteAllText(Path.Combine(projectDirectory, "MyHostingExtension.csproj"),
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RestoreConfigFile>{nuGetConfigPath}</RestoreConfigFile>
<!-- The synthetic fixture intentionally exposes no [AspireExport] surface (it only exercises
ASPIREEXPORT008), so opt out of polyglot compatibility to avoid the assembly-level
ASPIREEXPORT017 error that otherwise fires for an integration with no export coverage. -->
<IsAspirePolyglotCompatible>false</IsAspirePolyglotCompatible>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Integration.Analyzers" Version="{analyzerVersion}" PrivateAssets="all" />
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting\Aspire.Hosting.csproj" />
</ItemGroup>
</Project>
""");
File.WriteAllText(Path.Combine(projectDirectory, "Extensions.cs"),
"""
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
namespace MyHostingExtension;
public static class CustomResourceExtensions
{
public static IResourceBuilder<ContainerResource> AddCustomContainer(this IDistributedApplicationBuilder builder)
{
return builder.AddContainer("custom", "custom-image");
}
}
""");
var output = BuildProject(projectDirectory);
Assert.Contains("warning ASPIREEXPORT008", output);
Assert.DoesNotContain("CS8032", output);
}
private static void CreateExportAnalyzerDirectoryBuildFiles(
string basePath,
string repoRoot,
bool enableAspireIntegrationAnalyzers = false)
{
File.WriteAllText(Path.Combine(basePath, "Directory.Build.props"),
$"""
<Project>
<PropertyGroup>
<EnableAspireIntegrationAnalyzers>{enableAspireIntegrationAnalyzers.ToString().ToLowerInvariant()}</EnableAspireIntegrationAnalyzers>
<!-- The synthetic fixture intentionally exposes no [AspireExport] surface (it only exercises
ASPIREEXPORT008), so opt out of polyglot compatibility to avoid the assembly-level
ASPIREEXPORT017 error that otherwise fires for an integration with no export coverage. -->
<IsAspirePolyglotCompatible>false</IsAspirePolyglotCompatible>
</PropertyGroup>
</Project>
""");
File.WriteAllText(Path.Combine(basePath, "Directory.Build.targets"),
$"""
<Project>
<ItemGroup Condition="'$(EnableAspireIntegrationAnalyzers)' == 'true'">
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting.Integration.Analyzers\Aspire.Hosting.Integration.Analyzers.csproj"
PrivateAssets="all"
ReferenceOutputAssembly="false"
OutputItemType="Analyzer"
SetTargetFramework="TargetFramework=netstandard2.0" />
</ItemGroup>
<Import Project="{repoRoot}\src\Aspire.Hosting\buildTransitive\Aspire.Hosting.targets" />
</Project>
""");
}
/// <summary>
/// Tests that when GenerateAssemblyInfo is set to false, a build error is emitted.
/// </summary>
[Fact]
public void GenerateAssemblyInfoFalse_EmitsError()
{
var repoRoot = MSBuildUtils.GetRepoRoot();
using var workspace = TemporaryWorkspace.Create(outputHelper);
var appHostDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost");
Directory.CreateDirectory(appHostDirectory);
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.csproj"),
$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<!--
Test applications have their own way of referencing Aspire.Hosting.AppHost, as well as DCP and Dashboard, so we disable
the Aspire.AppHost.SDK targets that will automatically add these references to projects.
-->
<SkipAddAspireDefaultReferences Condition="'$(TestsRunningOutsideOfRepo)' != 'true'">true</SkipAddAspireDefaultReferences>
<AspireHostingSDKVersion>9.0.0</AspireHostingSDKVersion>
<_AspireUseTaskHostFactory>true</_AspireUseTaskHostFactory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="{repoRoot}\src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj" IsAspireProjectResource="false" />
</ItemGroup>
</Project>
""");
File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.cs"),
"""
var builder = DistributedApplication.CreateBuilder();
builder.Build().Run();
""");
CreateDirectoryBuildFiles(appHostDirectory, repoRoot);
// Build should fail
var output = new StringBuilder();
var outputDone = new ManualResetEvent(false);
using var process = new Process();
process.StartInfo = new ProcessStartInfo("dotnet", "build --disable-build-servers")
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = appHostDirectory
};
process.OutputDataReceived += (sender, e) =>
{
if (e.Data == null)
{
outputDone.Set();
}
else
{
output.AppendLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
Assert.True(process.WaitForExit(milliseconds: 180_000), "dotnet build command timed out after 3 minutes.");
Assert.True(outputDone.WaitOne(millisecondsTimeout: 60_000), "Timed out waiting for output to complete.");
var buildOutput = output.ToString();
// Build should fail with ASPIRE008 error
Assert.NotEqual(0, process.ExitCode);
Assert.Contains("error ASPIRE008", buildOutput);
Assert.Contains("GenerateAssemblyInfo", buildOutput);
}
}