// 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.Globalization;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Xml.Linq;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Layout;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Cli.Tests.Projects;
public class PrebuiltAppHostServerTests(ITestOutputHelper outputHelper)
{
private const string NuGetOrgSource = "https://api.nuget.org/v3/index.json";
private static readonly byte[] s_sourceIdentityKey = new byte[NuGetSourceIdentity.KeySizeInBytes];
[Fact]
public async Task WriteIfChangedAsync_LeavesAnIdenticalFileAlone()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var path = Path.Combine(workspace.WorkspaceRoot.FullName, "IntegrationRestore.csproj");
await PrebuiltAppHostServer.WriteIfChangedAsync(path, "<Project />", CancellationToken.None);
var firstWrite = File.GetLastWriteTimeUtc(path);
// MSBuild rebuilds when an input timestamp moves, so an identical rewrite must not touch the file.
File.SetLastWriteTimeUtc(path, firstWrite.AddMinutes(-5));
var backdated = File.GetLastWriteTimeUtc(path);
await PrebuiltAppHostServer.WriteIfChangedAsync(path, "<Project />", CancellationToken.None);
Assert.Equal(backdated, File.GetLastWriteTimeUtc(path));
Assert.Equal("<Project />", await File.ReadAllTextAsync(path));
}
[Fact]
public async Task WriteIfChangedAsync_WritesWhenContentDiffers()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var path = Path.Combine(workspace.WorkspaceRoot.FullName, "IntegrationRestore.csproj");
await PrebuiltAppHostServer.WriteIfChangedAsync(path, "<Project />", CancellationToken.None);
await PrebuiltAppHostServer.WriteIfChangedAsync(path, "<Project Sdk=\"Microsoft.NET.Sdk\" />", CancellationToken.None);
Assert.Equal("<Project Sdk=\"Microsoft.NET.Sdk\" />", await File.ReadAllTextAsync(path));
}
[Fact]
public async Task WriteIfChangedAsync_RewritesAFileItCannotRead()
{
Assert.SkipWhen(OperatingSystem.IsWindows(), "Read permission is removed with POSIX file modes.");
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var path = Path.Combine(workspace.WorkspaceRoot.FullName, "IntegrationRestore.csproj");
await File.WriteAllTextAsync(path, "<Project />");
// A generated file the CLI owns but momentarily cannot read -- an antivirus scanner holding it
// open on Windows, a mode the user changed on POSIX -- must be rewritten, not turned into a
// launch failure. The write below is what surfaces a genuinely unrecoverable error.
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(path, UnixFileMode.UserWrite);
}
await PrebuiltAppHostServer.WriteIfChangedAsync(path, "<Project Sdk=\"Microsoft.NET.Sdk\" />", CancellationToken.None);
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
Assert.Equal("<Project Sdk=\"Microsoft.NET.Sdk\" />", await File.ReadAllTextAsync(path));
}
[Fact]
public void GetIntegrationBuildFailureMessage_ExplainsAnAspireHostingPackageDowngrade()
{
var output = new OutputCollector();
// MSBuild localizes the diagnostic text, but the error code and package ID remain stable.
output.AppendOutput("IntegrationRestore.csproj : error NU1605: Advertencia como error: Degradación del paquete detectada: Aspire.Hosting de 13.6.0-dev a 13.5.0.");
var message = PrebuiltAppHostServer.GetIntegrationBuildFailureMessage(output);
Assert.Contains("aspire.config.json", message, StringComparison.Ordinal);
Assert.Contains(VersionHelper.GetDefaultTemplateVersion(), message, StringComparison.Ordinal);
}
[Fact]
public void GetIntegrationBuildFailureMessage_FallsBackForAnUnrelatedPackageDowngrade()
{
var output = new OutputCollector();
output.AppendOutput("IntegrationRestore.csproj : error NU1605: Advertencia como error: Degradación del paquete detectada: Conflict.Dependency de 2.0.0 a 1.0.0.");
output.AppendOutput("IntegrationRestore -> Aspire.Hosting (>= 13.6.0)");
Assert.Equal(ErrorStrings.IntegrationBuildFailed, PrebuiltAppHostServer.GetIntegrationBuildFailureMessage(output));
}
[Fact]
public void GetIntegrationBuildFailureMessage_FallsBackForOtherFailures()
{
var output = new OutputCollector();
output.AppendOutput("Program.cs(3,1): error CS0103: The name 'Foo' does not exist in the current context");
Assert.Equal(ErrorStrings.IntegrationBuildFailed, PrebuiltAppHostServer.GetIntegrationBuildFailureMessage(output));
}
[Fact]
public async Task DotNetRestore_PackageDowngradeIncludesAspireHostingOnTheDiagnosticLine()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var projectDirectory = workspace.CreateDirectory("project");
CreateDependencyPackage(feedDirectory, "Aspire.Hosting", "1.0.0");
CreateDependencyPackage(feedDirectory, "Aspire.Hosting", "2.0.0");
CreateDependencyPackage(
feedDirectory,
"Conflict.Requirement",
"1.0.0",
dependencyId: "Aspire.Hosting",
dependencyVersion: "[2.0.0]");
var projectPath = Path.Combine(projectDirectory.FullName, "PackageDowngrade.csproj");
await File.WriteAllTextAsync(
projectPath,
"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Conflict.Requirement" Version="1.0.0" />
<PackageReference Include="Aspire.Hosting" Version="1.0.0" />
</ItemGroup>
</Project>
""");
var nuGetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
await File.WriteAllTextAsync(
nuGetConfigPath,
$$"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="test" value="{{feedDirectory.FullName}}" />
</packageSources>
</configuration>
""");
var startInfo = new ProcessStartInfo("dotnet")
{
WorkingDirectory = projectDirectory.FullName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.Environment.Remove("MSBuildSDKsPath");
startInfo.ArgumentList.Add("restore");
startInfo.ArgumentList.Add(projectPath);
startInfo.ArgumentList.Add("--configfile");
startInfo.ArgumentList.Add(nuGetConfigPath);
startInfo.ArgumentList.Add("--packages");
startInfo.ArgumentList.Add(packagesDirectory.FullName);
startInfo.ArgumentList.Add("--nologo");
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet restore.");
// Read both streams concurrently to avoid deadlock when a pipe buffer fills.
var stdoutTask = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken);
var stderrTask = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken);
await process.WaitForExitAsync(TestContext.Current.CancellationToken);
var output = $"{await stdoutTask}{Environment.NewLine}{await stderrTask}";
var outputLines = output.Split(
[Environment.NewLine],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Assert.True(process.ExitCode != 0, $"Expected dotnet restore to fail with NU1605:{Environment.NewLine}{output}");
Assert.True(
outputLines.Any(static line =>
line.Contains("NU1605", StringComparison.Ordinal) &&
line.Contains(" Aspire.Hosting ", StringComparison.OrdinalIgnoreCase)),
$"Expected one NU1605 diagnostic line to contain the exact Aspire.Hosting package ID:{Environment.NewLine}{output}");
}
[Fact]
public async Task PackageScopedSourceRestore_AppendsIdentitySourceForMissingAspireDependency()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string identityChannel = "pr-12345";
const string identityVersion = "99.0.0";
const string selectedPackage = "ThirdParty.Aspire.Integration";
const string selectedPackageVersion = "1.0.0";
var explicitPackageSource = workspace.CreateDirectory("integration-feed");
var identityPackageSource = workspace.CreateDirectory("identity-feed");
var packagesDirectory = workspace.CreateDirectory("packages");
CreateDependencyPackage(
identityPackageSource,
"Aspire.Hosting",
identityVersion,
packageFiles: new Dictionary<string, string>(StringComparer.Ordinal)
{
["lib/net10.0/Aspire.Hosting.dll"] = "identity-hosting"
});
CreateDependencyPackage(
explicitPackageSource,
selectedPackage,
selectedPackageVersion,
dependencyId: "Aspire.Hosting",
dependencyVersion: $"[{identityVersion}]",
packageFiles: new Dictionary<string, string>(StringComparer.Ordinal)
{
[$"lib/net10.0/{selectedPackage}.dll"] = "selected-integration"
});
await File.WriteAllTextAsync(
Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config"),
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
</packageSources>
<auditSources>
<clear />
</auditSources>
<packageSourceMapping>
<clear />
</packageSourceMapping>
</configuration>
""");
var identityChannelPolicy = PackageChannel.CreateExplicitChannel(
name: identityChannel,
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", identityPackageSource.FullName)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
Task.FromResult<IEnumerable<PackageChannel>>([identityChannelPolicy])
};
var executionContext = CreateContextWithIdentityChannel(identityChannel, identityVersion);
var nuGetClient = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var nuGetService = new BundleNuGetService(
NullLogger<BundleNuGetService>.Instance,
nuGetClient)
{
SourceIdentityKeyFactory = static () => s_sourceIdentityKey
};
var resolver = new IntegrationRestorePlanResolver(
packagingService,
nuGetService,
executionContext,
NullLogger.Instance);
var restorePlan = await resolver.ResolveAsync(
workspace.WorkspaceRoot.FullName,
workloadId: "test-apphost",
sdkVersion: identityVersion,
requestedChannel: null,
packageSourceOverride: explicitPackageSource.FullName,
packageSourceOverridePattern: selectedPackage,
cancellationToken: TestContext.Current.CancellationToken);
using var restoreConfiguration = await restorePlan.CreatePackageRestoreConfigurationAsync(
TestContext.Current.CancellationToken);
var manifestPath = await nuGetService.RestorePackagesAsync(
[(selectedPackage, $"[{selectedPackageVersion}]")],
workspace.WorkspaceRoot.FullName,
sources: restoreConfiguration.Sources,
nugetConfigPaths: restoreConfiguration.ConfigPaths,
nugetSettingsCacheIdentity: restoreConfiguration.SettingsCacheIdentity,
nugetConfigOverlayCacheIdentity: restoreConfiguration.OverlayCacheIdentity,
additionalSensitiveSources: restoreConfiguration.SensitiveSources,
globalPackagesFolderOverride: packagesDirectory.FullName,
ct: TestContext.Current.CancellationToken);
Assert.True(File.Exists(manifestPath));
Assert.True(File.Exists(Path.Combine(
packagesDirectory.FullName,
selectedPackage.ToLowerInvariant(),
selectedPackageVersion,
"lib",
"net10.0",
$"{selectedPackage}.dll")));
Assert.True(File.Exists(Path.Combine(
packagesDirectory.FullName,
"aspire.hosting",
identityVersion,
"lib",
"net10.0",
"Aspire.Hosting.dll")));
var overlayFile = Assert.IsType<FileInfo>(restoreConfiguration.PolicyOverlayFile);
var overlay = XDocument.Load(overlayFile.FullName);
Assert.Equal(
[selectedPackage, PackageSourceOverrideMappings.DefaultPackagePattern, PackageMapping.AllPackages],
GetPackagePatternsForSource(overlay, explicitPackageSource.FullName));
Assert.Equal(
[PackageSourceOverrideMappings.DefaultPackagePattern],
GetPackagePatternsForSource(overlay, identityPackageSource.FullName));
}
[Fact]
public void GenerateIntegrationProjectFile_ProducesAspireHostingAndProjectReferences()
{
var projectRefs = new List<IntegrationReference>
{
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
};
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
projectRefs,
"/tmp/libs");
var doc = XDocument.Parse(xml);
var projectElements = doc.Descendants("ProjectReference").ToList();
Assert.Single(projectElements);
Assert.Equal("/path/to/MyIntegration.csproj", projectElements[0].Attribute("Include")?.Value);
Assert.Equal("false", projectElements[0].Element("IsAspireProjectResource")?.Value);
Assert.Equal("true", projectElements[0].Element("ReferenceOutputAssembly")?.Value);
Assert.Null(projectElements[0].Element("Private"));
var packageReference = Assert.Single(doc.Descendants("PackageReference"));
Assert.Equal("Aspire.Hosting", packageReference.Attribute("Include")?.Value);
Assert.Equal("13.5.0", packageReference.Attribute("Version")?.Value);
}
[Fact]
public void GenerateIntegrationProjectFile_IncludesEveryIntegrationPackageReference()
{
var projectRefs = new List<IntegrationReference>
{
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
};
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[
("Aspire.Hosting", "13.5.0"),
("Aspire.Hosting.Redis", "13.5.0")
],
projectRefs,
"/tmp/libs");
var doc = XDocument.Parse(xml);
Assert.Collection(
doc.Descendants("PackageReference"),
packageReference =>
{
Assert.Equal("Aspire.Hosting", packageReference.Attribute("Include")?.Value);
Assert.Equal("13.5.0", packageReference.Attribute("Version")?.Value);
},
packageReference =>
{
Assert.Equal("Aspire.Hosting.Redis", packageReference.Attribute("Include")?.Value);
Assert.Equal("13.5.0", packageReference.Attribute("Version")?.Value);
});
Assert.Single(doc.Descendants("ProjectReference"));
}
[Fact]
public void GenerateIntegrationProjectFile_PreservesExactAspireHostingVersion()
{
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "[13.5.0]")],
[],
"/tmp/libs");
var doc = XDocument.Parse(xml);
var packageReference = Assert.Single(doc.Descendants("PackageReference"));
Assert.Equal("[13.5.0]", packageReference.Attribute("Version")?.Value);
}
[Fact]
public void GenerateIntegrationProjectFile_DoesNotSetOutDir()
{
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
[],
"/custom/output/path");
var doc = XDocument.Parse(xml);
var ns = doc.Root!.GetDefaultNamespace();
Assert.Null(doc.Descendants(ns + "OutDir").FirstOrDefault());
}
[Fact]
public void GenerateIntegrationProjectFile_DoesNotSetEarlyOutputPathProperties()
{
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
[],
"/custom/output/path");
var doc = XDocument.Parse(xml);
var ns = doc.Root!.GetDefaultNamespace();
Assert.Null(doc.Descendants(ns + "BaseOutputPath").FirstOrDefault());
Assert.Null(doc.Descendants(ns + "BaseIntermediateOutputPath").FirstOrDefault());
Assert.Null(doc.Descendants(ns + "MSBuildProjectExtensionsPath").FirstOrDefault());
}
[Fact]
public void CreateClosureDirectoryBuildProps_SetsEarlyOutputPathProperties()
{
var doc = IntegrationClosureBuilder.CreateClosureDirectoryBuildProps(
"/custom/output/path",
"/custom/intermediate/path",
"/custom/policy/path",
"/custom/packages/path");
var ns = doc.Root!.GetDefaultNamespace();
Assert.Equal(
Path.Combine("/custom/output/path", "bin") + Path.DirectorySeparatorChar,
doc.Descendants(ns + "BaseOutputPath").FirstOrDefault()?.Value);
Assert.Equal(
"/custom/intermediate/path" + Path.DirectorySeparatorChar,
doc.Descendants(ns + "BaseIntermediateOutputPath").FirstOrDefault()?.Value);
Assert.Equal("$(BaseIntermediateOutputPath)", doc.Descendants(ns + "MSBuildProjectExtensionsPath").FirstOrDefault()?.Value);
Assert.Equal("/custom/policy/path", doc.Descendants(ns + "RestoreRootConfigDirectory").FirstOrDefault()?.Value);
Assert.Equal(string.Empty, doc.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value);
Assert.Equal("/custom/packages/path", doc.Descendants(ns + "RestorePackagesPath").FirstOrDefault()?.Value);
}
[Fact]
public async Task CreateClosureProjectFile_BuildEmitsClosureContract()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var integrationDirectory = workspace.CreateDirectory("MyIntegration");
var integrationProjectPath = Path.Combine(integrationDirectory.FullName, "MyIntegration.csproj");
await File.WriteAllTextAsync(integrationProjectPath, """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
""");
var projectDirectory = workspace.CreateDirectory("generated-project");
var restoreDirectory = workspace.CreateDirectory("integration-restore");
var projectPath = Path.Combine(projectDirectory.FullName, "GeneratedClosure.csproj");
var nuGetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
await File.WriteAllTextAsync(nuGetConfigPath, """
<configuration>
<packageSources>
<clear />
</packageSources>
</configuration>
""");
var projectFile = IntegrationClosureBuilder.CreateClosureProjectFile(
restoreDirectory.FullName,
additionalSources: null);
projectFile.ProjectReferences.Add(new CSharpProjectReference(
integrationProjectPath,
IsAspireProjectResource: false,
ReferenceOutputAssembly: true));
await File.WriteAllTextAsync(projectPath, projectFile.ToXDocument().ToString());
await File.WriteAllTextAsync(
Path.Combine(projectDirectory.FullName, "Directory.Build.props"),
IntegrationClosureBuilder.CreateClosureDirectoryBuildProps(
restoreDirectory.FullName,
Path.Combine(restoreDirectory.FullName, "obj"),
workspace.WorkspaceRoot.FullName,
globalPackagesFolder: null).ToString());
var startInfo = new ProcessStartInfo("dotnet")
{
WorkingDirectory = projectDirectory.FullName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.Environment.Remove("MSBuildSDKsPath");
startInfo.ArgumentList.Add("build");
startInfo.ArgumentList.Add(projectPath);
startInfo.ArgumentList.Add("--nologo");
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet build.");
// Read both streams concurrently to avoid deadlock when a pipe buffer fills.
var stdoutTask = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken);
var stderrTask = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken);
await process.WaitForExitAsync(TestContext.Current.CancellationToken);
var stdout = await stdoutTask;
var stderr = await stderrTask;
Assert.True(process.ExitCode == 0, $"dotnet build failed:{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}");
Assert.True(File.Exists(Path.Combine(restoreDirectory.FullName, "obj", IntegrationClosureBuilder.ProjectAssetsFileName)));
Assert.True(File.Exists(Path.Combine(restoreDirectory.FullName, "bin", "Debug", "net10.0", "GeneratedClosure.dll")));
Assert.False(Directory.Exists(Path.Combine(projectDirectory.FullName, "obj")));
Assert.False(Directory.Exists(Path.Combine(projectDirectory.FullName, "bin")));
var sourcePaths = await File.ReadAllLinesAsync(
Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureSourcesFileName));
Assert.Equal(["MyIntegration.dll", "MyIntegration.pdb"], sourcePaths.Select(Path.GetFileName));
Assert.All(sourcePaths, path => Assert.True(File.Exists(path)));
Assert.Equal(
["|||", "|||"],
await File.ReadAllLinesAsync(Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureMetadataFileName)));
Assert.Equal(
["MyIntegration.dll", "MyIntegration.pdb"],
await File.ReadAllLinesAsync(Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureTargetsFileName)));
Assert.Equal(
["MyIntegration"],
await File.ReadAllLinesAsync(Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ProjectRefAssemblyNamesFileName)));
}
[Theory]
[InlineData("[1.0.0,)", true)]
[InlineData("[1.0.0]", false)]
public async Task GenerateIntegrationProjectFile_RestoresPackageAndProjectReferencesInOneNuGetGraph(
string directDependencyRange,
bool shouldSucceed)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
CreateDependencyPackage(feedDirectory, "Shared.Dependency", "1.0.0");
CreateDependencyPackage(feedDirectory, "Shared.Dependency", "2.0.0");
// This package is used only by the referenced project so the test cannot pass merely because
// the generated root happened to populate the shared package cache before graph evaluation.
CreateDependencyPackage(feedDirectory, "Project.Dependency", "1.0.0");
CreateDependencyPackage(
feedDirectory,
"Direct.Integration",
"1.0.0",
dependencyId: "Shared.Dependency",
dependencyVersion: directDependencyRange,
packageFiles: new Dictionary<string, string>(StringComparer.Ordinal)
{
[$"runtimes/{RuntimeInformation.RuntimeIdentifier}/native/hostnative.bin"] = "host-native",
["runtimes/excluded-test-rid/native/othernative.bin"] = "other-native"
});
var projectIntegrationDirectory = workspace.CreateDirectory("ProjectIntegration");
var projectIntegrationPath = Path.Combine(projectIntegrationDirectory.FullName, "ProjectIntegration.csproj");
await File.WriteAllTextAsync(projectIntegrationPath, """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<_AspireIntegrationNuGetConfig>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(BaseIntermediateOutputPath)AspireIntegration.NuGet.Config'))</_AspireIntegrationNuGetConfig>
<!--
NuGet computes the project's source locations before the config below is applied,
so the source still has to be added here. The injected config supplies the alias
mapping that makes this source eligible under the ambient mapping policy.
-->
<RestoreAdditionalProjectSources>$(RestoreAdditionalProjectSources);$(AspireIntegrationPackageSources)</RestoreAdditionalProjectSources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Project.Dependency" Version="1.0.0" />
<PackageReference Include="Shared.Dependency" Version="2.0.0" />
</ItemGroup>
<!--
This is an intentionally unsupported NuGet escape hatch. It proves that a project
can use Aspire's source and alias hints to add mapping configuration while retaining
the config paths NuGet discovered for that project.
-->
<Target Name="WriteAspireIntegrationNuGetConfig"
BeforeTargets="_GenerateRestoreProjectSpec">
<Error Condition="'$(AspireIntegrationPackageSources)' == ''"
Text="AspireIntegrationPackageSources was not provided." />
<Error Condition="'$(AspireIntegrationPackageSourceAlias)' == ''"
Text="AspireIntegrationPackageSourceAlias was not provided." />
<PropertyGroup>
<_AspireIntegrationPackageSource>$([System.String]::Copy('$(AspireIntegrationPackageSources)').Split(';').GetValue(0))</_AspireIntegrationPackageSource>
<_AspireIntegrationNuGetConfigContent><![CDATA[
<configuration>
<packageSources>
<add key="$(AspireIntegrationPackageSourceAlias)" value="$(_AspireIntegrationPackageSource)" />
</packageSources>
<packageSourceMapping>
<packageSource key="$(AspireIntegrationPackageSourceAlias)">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
]]></_AspireIntegrationNuGetConfigContent>
</PropertyGroup>
<MakeDir Directories="$([System.IO.Path]::GetDirectoryName('$(_AspireIntegrationNuGetConfig)'))" />
<WriteLinesToFile File="$(_AspireIntegrationNuGetConfig)"
Lines="$(_AspireIntegrationNuGetConfigContent)"
Overwrite="true" />
</Target>
<Target Name="ApplyAspireIntegrationNuGetConfig"
AfterTargets="_GenerateRestoreProjectSpec"
DependsOnTargets="WriteAspireIntegrationNuGetConfig">
<ItemGroup>
<_RestoreGraphEntry Update="@(_RestoreGraphEntry)"
Condition="'%(_RestoreGraphEntry.Type)' == 'ProjectSpec' and
'%(_RestoreGraphEntry.ProjectUniqueName)' == '$(MSBuildProjectFullPath)'">
<ConfigFilePaths>$(_AspireIntegrationNuGetConfig);%(_RestoreGraphEntry.ConfigFilePaths)</ConfigFilePaths>
</_RestoreGraphEntry>
</ItemGroup>
</Target>
<Target Name="RejectInheritedRuntimeIdentifier"
BeforeTargets="Build"
Condition="'$(RuntimeIdentifier)' != ''">
<Error Text="The generated root's RuntimeIdentifier must not be imposed on referenced projects." />
</Target>
</Project>
""");
var generatedProjectDirectory = workspace.CreateDirectory("generated-project");
var restoreDirectory = workspace.CreateDirectory("integration-restore");
// NuGet merges package sources and source mappings independently. Clear both sections so
// user-level settings cannot leak into the restore, then declare a single ambient source for
// framework reference packs that the local SDK may not carry. CI cannot reach nuget.org, so
// honor the same service index override the CLI uses. Mapping that source to '*' enables
// package-source mapping for every project in the graph, which is what requires the referenced
// project to map the test feed through the alias hint rather than merely listing it.
var configuredNuGetServiceIndex = Environment.GetEnvironmentVariable(AspireCliIdentityEnvVars.NuGetServiceIndex);
var ambientServiceIndex = string.IsNullOrWhiteSpace(configuredNuGetServiceIndex)
? PackageSources.NuGetOrg
: configuredNuGetServiceIndex;
new XDocument(
new XElement("configuration",
new XElement("packageSources",
new XElement("clear"),
new XElement("add",
new XAttribute("key", "ambient"),
new XAttribute("value", ambientServiceIndex))),
new XElement("packageSourceMapping",
new XElement("clear"),
new XElement("packageSource",
new XAttribute("key", "ambient"),
new XElement("package", new XAttribute("pattern", "*"))))))
.Save(Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config"));
var (server, _) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.DiscoverAmbient);
var restorePlan = await server.ResolveIntegrationRestorePlanAsync(
VersionHelper.GetDefaultTemplateVersion(),
requestedChannel: null,
packageSourceOverride: feedDirectory.FullName,
packageSourceOverridePattern: "Direct.Integration",
TestContext.Current.CancellationToken);
var restoreConfiguration = restorePlan.ApplyProjectRestoreConfiguration(
IntegrationClosureBuilder.GetAppHostIntegrationPolicyDirectory(workspace.WorkspaceRoot),
TestContext.Current.CancellationToken);
var integrationPackageSources = IntegrationClosureBuilder.CreateRestoreAdditionalProjectSourcesValue(
existingValue: null,
restoreConfiguration.PackageSourceHints);
var generatedProjectPath = Path.Combine(generatedProjectDirectory.FullName, "IntegrationRestore.csproj");
var projectContent = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Direct.Integration", "1.0.0")],
[IntegrationReference.FromProject("ProjectIntegration", projectIntegrationPath)],
restoreDirectory.FullName,
restoreConfiguration.RootAdditionalSources);
await File.WriteAllTextAsync(generatedProjectPath, projectContent);
await File.WriteAllTextAsync(
Path.Combine(generatedProjectDirectory.FullName, "Directory.Build.props"),
IntegrationClosureBuilder.CreateClosureDirectoryBuildProps(
restoreDirectory.FullName,
Path.Combine(restoreDirectory.FullName, "obj"),
restoreConfiguration.RestoreRootConfigDirectory,
Path.Combine(workspace.WorkspaceRoot.FullName, "packages")).ToString());
await File.WriteAllTextAsync(
Path.Combine(generatedProjectDirectory.FullName, "Directory.Packages.props"),
"""
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
""");
var restoreConfig = XDocument.Load(Path.Combine(
restoreConfiguration.RestoreRootConfigDirectory,
"NuGet.Config"));
var configuredSources = restoreConfig
.Descendants("packageSources")
.Elements("add")
.Select(static source => source.Attribute("value")!.Value)
.ToArray();
Assert.Equal([feedDirectory.FullName], configuredSources);
Assert.Equal(["*", "Aspire*"], GetPackagePatternsForKey(restoreConfig, "ambient"));
Assert.Equal(
["Direct.Integration", "Aspire*", "*"],
GetPackagePatternsForSource(restoreConfig, feedDirectory.FullName));
var startInfo = new ProcessStartInfo("dotnet")
{
WorkingDirectory = generatedProjectDirectory.FullName,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.Environment.Remove("MSBuildSDKsPath");
// The referenced project restores into the default global packages folder. Isolate it so a
// package cached by an earlier run cannot satisfy restore without the test feed being eligible.
startInfo.Environment["NUGET_PACKAGES"] = workspace.CreateDirectory("project-packages").FullName;
if (integrationPackageSources is not null)
{
startInfo.Environment[PrebuiltAppHostServer.IntegrationPackageSourcesPropertyName] = integrationPackageSources;
}
Assert.NotNull(restoreConfiguration.PackageSourceAlias);
startInfo.Environment[PrebuiltAppHostServer.IntegrationPackageSourceAliasPropertyName] =
restoreConfiguration.PackageSourceAlias;
startInfo.ArgumentList.Add("build");
startInfo.ArgumentList.Add(generatedProjectPath);
startInfo.ArgumentList.Add("--nologo");
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet build.");
var stdoutTask = process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken);
var stderrTask = process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken);
await process.WaitForExitAsync(TestContext.Current.CancellationToken);
var output = $"{await stdoutTask}{Environment.NewLine}{await stderrTask}";
var projectNuGetConfigPath = Path.Combine(
projectIntegrationDirectory.FullName,
"obj",
"AspireIntegration.NuGet.Config");
Assert.True(File.Exists(projectNuGetConfigPath), output);
var projectNuGetConfig = XDocument.Load(projectNuGetConfigPath);
var projectSource = Assert.Single(projectNuGetConfig.Descendants("packageSources").Elements("add"));
Assert.Equal(restoreConfiguration.PackageSourceAlias, projectSource.Attribute("key")?.Value);
Assert.Equal(feedDirectory.FullName, projectSource.Attribute("value")?.Value);
if (shouldSucceed)
{
Assert.True(process.ExitCode == 0, output);
using var assets = JsonDocument.Parse(await File.ReadAllTextAsync(
Path.Combine(restoreDirectory.FullName, "obj", IntegrationClosureBuilder.ProjectAssetsFileName)));
Assert.True(assets.RootElement.GetProperty("libraries").TryGetProperty("Project.Dependency/1.0.0", out _));
Assert.True(assets.RootElement.GetProperty("libraries").TryGetProperty("Shared.Dependency/2.0.0", out _));
Assert.False(assets.RootElement.GetProperty("libraries").TryGetProperty("Shared.Dependency/1.0.0", out _));
var closureSources = await File.ReadAllLinesAsync(
Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureSourcesFileName));
var closureMetadata = await File.ReadAllLinesAsync(
Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureMetadataFileName));
var nativeEntry = Assert.Single(
closureSources.Zip(closureMetadata),
static entry => entry.Second.EndsWith("|native", StringComparison.Ordinal));
Assert.EndsWith(
Path.Combine("runtimes", RuntimeInformation.RuntimeIdentifier, "native", "hostnative.bin"),
nativeEntry.First,
StringComparison.Ordinal);
Assert.Equal(
$"Direct.Integration|1.0.0|runtimes/{RuntimeInformation.RuntimeIdentifier}/native/hostnative.bin|native",
nativeEntry.Second);
}
else
{
Assert.False(process.ExitCode == 0, output);
Assert.Contains("NU1107", output, StringComparison.Ordinal);
}
}
[Fact]
public void GenerateIntegrationProjectFile_WritesClosureManifestFiles()
{
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
[],
"/tmp/work");
var doc = XDocument.Parse(xml);
var ns = doc.Root!.GetDefaultNamespace();
Assert.Equal(RuntimeInformation.RuntimeIdentifier, doc.Descendants(ns + "RuntimeIdentifier").Single().Value);
Assert.Equal(Path.Combine("/tmp/work", IntegrationClosureBuilder.ClosureMetadataFileName), doc.Descendants(ns + "AspireClosureMetadataFile").FirstOrDefault()?.Value);
Assert.Equal(Path.Combine("/tmp/work", IntegrationClosureBuilder.ClosureSourcesFileName), doc.Descendants(ns + "AspireClosureSourcesFile").FirstOrDefault()?.Value);
Assert.Equal(Path.Combine("/tmp/work", IntegrationClosureBuilder.ClosureTargetsFileName), doc.Descendants(ns + "AspireClosureTargetsFile").FirstOrDefault()?.Value);
Assert.Equal(Path.Combine("/tmp/work", IntegrationClosureBuilder.ProjectRefAssemblyNamesFileName), doc.Descendants(ns + "AspireProjectRefAssemblyNamesFile").FirstOrDefault()?.Value);
}
[Fact]
public void GenerateIntegrationProjectFile_WritesClosureManifestTarget()
{
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
[],
"/tmp/work");
var doc = XDocument.Parse(xml);
var target = doc.Descendants("Target")
.FirstOrDefault(element => element.Attribute("Name")?.Value == "_WriteAspireClosureManifest");
Assert.NotNull(target);
Assert.Equal("Build", target.Attribute("AfterTargets")?.Value);
Assert.Equal("ResolveLockFileCopyLocalFiles", target.Attribute("DependsOnTargets")?.Value);
}
[Fact]
public void GenerateIntegrationProjectFile_HasCopyLocalLockFileAssemblies()
{
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
[],
"/tmp/libs");
var doc = XDocument.Parse(xml);
var ns = doc.Root!.GetDefaultNamespace();
var copyLocal = doc.Descendants(ns + "CopyLocalLockFileAssemblies").FirstOrDefault()?.Value;
Assert.Equal("true", copyLocal);
}
[Fact]
public void GenerateIntegrationProjectFile_DisablesAnalyzersAndDocGen()
{
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
[],
"/tmp/libs");
var doc = XDocument.Parse(xml);
var ns = doc.Root!.GetDefaultNamespace();
Assert.Equal("false", doc.Descendants(ns + "EnableDefaultItems").FirstOrDefault()?.Value);
Assert.Equal("false", doc.Descendants(ns + "EnableNETAnalyzers").FirstOrDefault()?.Value);
Assert.Equal("false", doc.Descendants(ns + "GenerateDocumentationFile").FirstOrDefault()?.Value);
Assert.Equal("false", doc.Descendants(ns + "IsPackable").FirstOrDefault()?.Value);
Assert.Equal("false", doc.Descendants(ns + "IsPublishable").FirstOrDefault()?.Value);
Assert.Equal("false", doc.Descendants(ns + "ProduceReferenceAssembly").FirstOrDefault()?.Value);
}
[Fact]
public void GenerateIntegrationProjectFile_TargetsNet10()
{
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
[],
"/tmp/libs");
var doc = XDocument.Parse(xml);
var ns = doc.Root!.GetDefaultNamespace();
Assert.Equal("net10.0", doc.Descendants(ns + "TargetFramework").FirstOrDefault()?.Value);
}
[Fact]
public void GenerateIntegrationProjectFile_WithAdditionalSources_SetsRestoreAdditionalProjectSources()
{
var sources = new[] { "/local/packages", "https://my-feed/v3/index.json" };
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
[],
"/tmp/libs",
sources);
var doc = XDocument.Parse(xml);
var ns = doc.Root!.GetDefaultNamespace();
var restoreConfigFile = doc.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value;
var restoreSources = doc.Descendants(ns + "RestoreAdditionalProjectSources").Single().Value;
Assert.Null(restoreConfigFile);
Assert.Equal("/local/packages;https://my-feed/v3/index.json", restoreSources);
}
[Fact]
public void GenerateIntegrationProjectFile_WithEmptyAdditionalSources_OverridesEnvironmentDefault()
{
var xml = PrebuiltAppHostServer.GenerateIntegrationProjectFile(
[("Aspire.Hosting", "13.5.0")],
[],
"/tmp/libs",
Enumerable.Empty<string>());
var doc = XDocument.Parse(xml);
var ns = doc.Root!.GetDefaultNamespace();
var restoreSources = doc.Descendants(ns + "RestoreAdditionalProjectSources").Single();
Assert.Equal(string.Empty, restoreSources.Value);
}
[Fact]
public void Constructor_UsesWorkspaceAspireDirectoryForWorkingDirectory()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var appHostDirectory = workspace.CreateDirectory("apphost");
var server = CreatePrebuiltAppHostServer(workspace, appPath: appHostDirectory.FullName);
var workingDirectory = Assert.IsType<string>(
typeof(PrebuiltAppHostServer)
.GetField("_workingDirectory", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(server));
var normalizedWorkspaceRoot = PathNormalizer.ResolveToFilesystemPath(workspace.WorkspaceRoot.FullName);
var rootDirectory = Path.Combine(normalizedWorkspaceRoot, ".aspire", "integrations", "apphosts");
var isUnderRoot = workingDirectory.StartsWith(rootDirectory, StringComparison.OrdinalIgnoreCase);
var parentDirectory = Path.GetDirectoryName(workingDirectory);
var isDirectChildOfRoot = parentDirectory is not null &&
string.Equals(parentDirectory, rootDirectory, StringComparison.OrdinalIgnoreCase);
var isSafeToDelete = isUnderRoot && isDirectChildOfRoot && !string.Equals(workingDirectory, rootDirectory, StringComparison.OrdinalIgnoreCase);
try
{
Assert.True(isSafeToDelete);
}
finally
{
if (isSafeToDelete && Directory.Exists(workingDirectory))
{
Directory.Delete(workingDirectory, recursive: true);
}
}
}
[Fact]
public async Task PrepareAsync_WithoutRequestedChannelAndCurrentCliVersion_UsesIdentityLocalPackageSource()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string identityChannel = "pr-12345";
const string identityVersion = "13.4.0-pr.17141.gf142085f";
var packageSource = workspace.CreateDirectory("hive-packages");
var channel = PackageChannel.CreateExplicitChannel(
name: identityChannel,
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", packageSource.FullName)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var executionContext = CreateContextWithIdentityChannel(identityChannel, identityVersion);
var (server, nuGetClient) = CreatePackageReferenceServer(
workspace,
NuGetSettingsMode.Isolated,
packagingService,
executionContext);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
identityVersion,
[IntegrationReference.FromPackage("Aspire.Hosting.Redis", identityVersion)]);
Assert.True(result.Success);
Assert.Equal([packageSource.FullName], GetConfiguredPackageSources(nuGetClient));
Assert.Contains(("Aspire.Hosting.Redis", $"[{identityVersion}]"), nuGetClient.LastRestorePackages!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithExactPackageSourceOverrideAndCurrentCliVersion_AppendsIdentityLocalPackageSource()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string identityChannel = "pr-12345";
const string identityVersion = "13.4.0-pr.17141.gf142085f";
const string selectedPackage = "CommunityToolkit.Aspire.Hosting.Redis";
var packageSourceOverride = workspace.CreateDirectory("integration-packages");
var identityPackageSource = workspace.CreateDirectory("identity-packages");
var channel = PackageChannel.CreateExplicitChannel(
name: identityChannel,
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", identityPackageSource.FullName)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var executionContext = CreateContextWithIdentityChannel(identityChannel, identityVersion);
var (server, nuGetClient) = CreatePackageReferenceServer(
workspace,
NuGetSettingsMode.Isolated,
packagingService,
executionContext);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
identityVersion,
[
IntegrationReference.FromPackage("Aspire.Hosting", identityVersion),
IntegrationReference.FromPackage(selectedPackage, "1.0.0")
],
packageSourceOverride: packageSourceOverride.FullName,
packageSourceOverridePattern: selectedPackage);
Assert.True(result.Success);
Assert.Equal(
[packageSourceOverride.FullName, identityPackageSource.FullName],
GetConfiguredPackageSources(nuGetClient));
Assert.Contains(("Aspire.Hosting", identityVersion), nuGetClient.LastRestorePackages!);
Assert.Contains((selectedPackage, "[1.0.0]"), nuGetClient.LastRestorePackages!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithoutRequestedChannelAndDifferentSdkVersion_IgnoresIdentityLocalPackageSource()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string identityChannel = "pr-12345";
const string identityVersion = "13.4.0-pr.17141.gf142085f";
const string requestedVersion = "13.3.0";
var packageSource = workspace.CreateDirectory("hive-packages");
var channel = PackageChannel.CreateExplicitChannel(
name: identityChannel,
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", packageSource.FullName)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var executionContext = CreateContextWithIdentityChannel(identityChannel, identityVersion);
var (server, nuGetClient) = CreatePackageReferenceServer(
workspace,
NuGetSettingsMode.Isolated,
packagingService,
executionContext);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
requestedVersion,
[IntegrationReference.FromPackage("Aspire.Hosting.Redis", requestedVersion)]);
Assert.True(result.Success);
Assert.Empty(GetConfiguredPackageSources(nuGetClient));
Assert.Contains(("Aspire.Hosting.Redis", requestedVersion), nuGetClient.LastRestorePackages!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithExplicitStableChannelAndCurrentCliVersion_IgnoresIdentityLocalPackageSource()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string identityChannel = "pr-12345";
const string identityVersion = "13.4.0-pr.17141.gf142085f";
var packageSource = workspace.CreateDirectory("hive-packages");
var stableChannel = PackageChannel.CreateExplicitChannel(
name: PackageChannelNames.Stable,
quality: PackageChannelQuality.Stable,
mappings: [new PackageMapping("Aspire*", NuGetOrgSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var identityPackageChannel = PackageChannel.CreateExplicitChannel(
name: identityChannel,
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", packageSource.FullName)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(
[stableChannel, identityPackageChannel])
};
var executionContext = CreateContextWithIdentityChannel(identityChannel, identityVersion);
var (server, nuGetClient) = CreatePackageReferenceServer(
workspace,
NuGetSettingsMode.Isolated,
packagingService,
executionContext);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
identityVersion,
[IntegrationReference.FromPackage("Aspire.Hosting.Redis", identityVersion)],
requestedChannel: PackageChannelNames.Stable);
Assert.True(result.Success);
Assert.Empty(GetConfiguredPackageSources(nuGetClient));
Assert.DoesNotContain(packageSource.FullName, GetConfiguredPackageSources(nuGetClient));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public void Constructor_UsesDistinctWorkingDirectoriesForMultipleAppHostsInSameWorkspace()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var firstAppHost = workspace.CreateDirectory(Path.Combine("apps", "api"));
var secondAppHost = workspace.CreateDirectory(Path.Combine("apps", "web"));
PrebuiltAppHostServer CreateServer(string appHostDirectory) =>
CreatePrebuiltAppHostServer(workspace, appPath: appHostDirectory);
var firstServer = CreateServer(firstAppHost.FullName);
var secondServer = CreateServer(secondAppHost.FullName);
var workingDirectoryField = typeof(PrebuiltAppHostServer)
.GetField("_workingDirectory", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var firstWorkingDirectory = Assert.IsType<string>(workingDirectoryField.GetValue(firstServer));
var secondWorkingDirectory = Assert.IsType<string>(workingDirectoryField.GetValue(secondServer));
var normalizedWorkspaceRoot = PathNormalizer.ResolveToFilesystemPath(workspace.WorkspaceRoot.FullName);
var appHostsRoot = Path.Combine(normalizedWorkspaceRoot, ".aspire", "integrations", "apphosts");
try
{
Assert.StartsWith(appHostsRoot, firstWorkingDirectory, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(appHostsRoot, secondWorkingDirectory, StringComparison.OrdinalIgnoreCase);
Assert.NotEqual(firstWorkingDirectory, secondWorkingDirectory);
}
finally
{
foreach (var dir in new[] { firstWorkingDirectory, secondWorkingDirectory })
{
if (Directory.Exists(dir))
{
Directory.Delete(dir, recursive: true);
}
}
}
}
[Fact]
public void GetAppHostIntegrationCacheDirectory_NormalizesSymlinkedAppHostDirectory()
{
Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix symlink canonicalization is covered by this test.");
using var workspace = TemporaryWorkspace.Create(outputHelper);
var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real-apphost");
var linkDirectoryPath = Path.Combine(workspace.WorkspaceRoot.FullName, "linked-apphost");
TestSymlinkHelper.TryCreateSymlink(linkDirectoryPath, realDirectory.FullName);
var realCacheDirectory = IntegrationClosureBuilder.GetAppHostIntegrationCacheDirectory(realDirectory);
var linkCacheDirectory = IntegrationClosureBuilder.GetAppHostIntegrationCacheDirectory(new DirectoryInfo(linkDirectoryPath));
Assert.Equal(realCacheDirectory.FullName, linkCacheDirectory.FullName);
}
[Fact]
public async Task GetAppHostIntegrationCacheDirectory_PreservesLexicalWorkspaceForExternalSymlinkTarget()
{
Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix symlink canonicalization is covered by this test.");
using var workspace = TemporaryWorkspace.Create(outputHelper);
var lexicalWorkspace = workspace.WorkspaceRoot.CreateSubdirectory("workspace");
await File.WriteAllTextAsync(Path.Combine(lexicalWorkspace.FullName, AspireConfigFile.FileName), "{}");
var externalAppHost = workspace.WorkspaceRoot.CreateSubdirectory("external-apphost");
var linkedAppHostPath = Path.Combine(lexicalWorkspace.FullName, "apphost");
TestSymlinkHelper.TryCreateSymlink(linkedAppHostPath, externalAppHost.FullName);
var externalCacheDirectory = IntegrationClosureBuilder.GetAppHostIntegrationCacheDirectory(externalAppHost);
var linkedCacheDirectory = IntegrationClosureBuilder.GetAppHostIntegrationCacheDirectory(new DirectoryInfo(linkedAppHostPath));
Assert.StartsWith(
PathNormalizer.ResolveToFilesystemPath(
Path.Combine(lexicalWorkspace.FullName, ".aspire", "integrations", "apphosts")) + Path.DirectorySeparatorChar,
linkedCacheDirectory.FullName,
StringComparisons.FileSystemPath);
Assert.Equal(externalCacheDirectory.Name, linkedCacheDirectory.Name);
}
// The local pseudo-channel has no package-source mappings, so it does not require an overlay.
// Channels with a selected source policy emit it regardless of the running CLI identity.
[Fact]
public async Task CreateRestoreOverlay_LocalIdentity_LocalRequested_ReturnsNull()
{
// A local pseudo-channel does not define a package-source mapping policy.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("local");
var server = CreateServerWithExplicitChannel(workspace, "local", executionContext);
using var result = await CreateRestoreOverlayAsync(server, "local");
Assert.Null(result.PolicyOverlayFile);
}
[Fact]
public async Task CreateRestoreOverlay_LocalIdentity_PrRequested_EmitsOverlay()
{
// The project-requested channel determines the overlay independently of the CLI identity.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("local");
var server = CreateServerWithExplicitChannel(workspace, "pr-12345", executionContext);
using var result = await CreateRestoreOverlayAsync(server, "pr-12345");
Assert.NotNull(result.PolicyOverlayFile);
}
[Fact]
public async Task CreateRestoreOverlay_StableIdentity_StableRequested_ReturnsNull()
{
// Stable uses the same ambient NuGet source policy as an omitted channel.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("stable");
var server = CreateServerWithExplicitChannel(workspace, "stable", executionContext);
using var result = await CreateRestoreOverlayAsync(server, "stable");
Assert.Null(result.PolicyOverlayFile);
}
[Fact]
public async Task CreateRestoreOverlay_StableIdentity_LocalRequested_ReturnsNull()
{
// Overlay selection depends on the project-requested channel, not the CLI identity.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("stable");
var server = CreateServerWithExplicitChannel(workspace, "local", executionContext);
using var result = await CreateRestoreOverlayAsync(server, "local");
Assert.Null(result.PolicyOverlayFile);
}
[Fact]
public async Task CreateRestoreOverlay_DailyIdentity_DailyRequested_EmitsOverlay()
{
// An explicit daily channel supplies its package-source mapping overlay.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("daily");
var server = CreateServerWithExplicitChannel(workspace, "daily", executionContext);
using var result = await CreateRestoreOverlayAsync(server, "daily");
Assert.NotNull(result.PolicyOverlayFile);
}
[Fact]
public async Task CreateRestoreOverlay_PrIdentity_DifferentPrRequested_EmitsOverlay()
{
// The requested PR channel supplies the overlay independently of the running PR build.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("pr-67890");
var server = CreateServerWithExplicitChannel(workspace, "pr-12345", executionContext);
using var result = await CreateRestoreOverlayAsync(server, "pr-12345");
Assert.NotNull(result.PolicyOverlayFile);
}
[Fact]
public async Task CreateRestoreOverlay_LocalIdentity_StagingRequested_EmitsOverlayWithGlobalPackagesFolder()
{
// The policy overlay is temporary, so the source-specific package cache must use a stable
// absolute path that survives overlay cleanup and remains valid for the package manifest.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("local");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var mappings = new[]
{
new PackageMapping("Aspire*", channelSource)
};
var stagingChannel = PackageChannel.CreateExplicitChannel(
name: "staging",
quality: PackageChannelQuality.Both,
mappings: mappings,
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
configureGlobalPackagesFolder: true);
var server = CreateServerWithChannel(workspace, stagingChannel, executionContext);
using var result = await CreateRestoreOverlayAsync(server, "staging");
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
var gpf = doc.Descendants("config")
.SelectMany(c => c.Elements("add"))
.FirstOrDefault(a => string.Equals(a.Attribute("key")?.Value, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase));
Assert.NotNull(gpf);
var gpfValue = gpf.Attribute("value")?.Value;
Assert.False(string.IsNullOrEmpty(gpfValue));
Assert.True(Path.IsPathFullyQualified(gpfValue), $"globalPackagesFolder value must be an absolute path. Got: {gpfValue}");
// The overlay directory is recursively deleted on dispose; the cache must live elsewhere
// so manifest paths produced by BundleNuGetService remain valid for the AppHost's lifetime.
var overlayDirectory = overlayFile.Directory!.FullName;
Assert.False(
gpfValue!.StartsWith(overlayDirectory, StringComparison.Ordinal),
$"globalPackagesFolder must not be under the policy overlay directory '{overlayDirectory}'. Got: {gpfValue}");
// The cache subdirectory must be keyed by the resolved feed URL so two different staging
// feeds (e.g. two darc builds or an overrideStagingFeed setting) get distinct caches.
var sourcePolicyIdentity = IntegrationRestorePlanResolver.CreateGlobalPackagesFolderIdentity(
[channelSource],
mappings);
var expectedIdentity = BundleNuGetService.CombineCacheIdentities(
sourcePolicyIdentity,
result.OverlayCacheIdentity!);
var expectedCacheKey = CliPathHelper.ComputeStagingCacheIdentityKey(expectedIdentity);
Assert.NotNull(expectedCacheKey);
var expectedCachePath = Path.Combine(
CliPathHelper.GetStagingNuGetPackagesDirectory(executionContext.AspireHomeDirectory),
expectedCacheKey);
Assert.Equal(expectedCachePath, gpfValue);
}
[Fact]
public async Task CreateRestoreOverlay_StagingRequested_FromRealPackagingService_EmitsStableGlobalPackagesFolderOutsideOverlayDirectory()
{
// A staging channel synthesized by the real packaging service must select an absolute
// package cache outside the temporary overlay directory. Package manifests retain paths
// into this cache after the overlay is disposed.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string overrideStagingFeed = "https://pkgs.dev.azure.com/dnceng/internal/_packaging/darc-pub-microsoft-aspire-deadbeef/nuget/v3/index.json";
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace,
identityChannel: PackageChannelNames.Staging);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = overrideStagingFeed
})
.Build();
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
configuration,
NullLogger<PackagingService>.Instance);
var server = CreateServerWithPackagingService(workspace, packagingService, executionContext);
using var result = await CreateRestoreOverlayAsync(server, PackageChannelNames.Staging);
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
var gpf = doc.Descendants("config")
.SelectMany(c => c.Elements("add"))
.FirstOrDefault(a => string.Equals(a.Attribute("key")?.Value, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase));
Assert.NotNull(gpf);
var gpfValue = gpf.Attribute("value")?.Value;
Assert.False(string.IsNullOrEmpty(gpfValue));
Assert.True(Path.IsPathFullyQualified(gpfValue), $"globalPackagesFolder value must be an absolute path. Got: {gpfValue}");
var overlayDirectory = overlayFile.Directory!.FullName;
Assert.False(
gpfValue!.StartsWith(overlayDirectory, StringComparison.Ordinal),
$"globalPackagesFolder must not be under the policy overlay directory '{overlayDirectory}'. Got: {gpfValue}");
// The cache key is derived from the resolved staging feed URL so the same CLI talking to
// a different overrideStagingFeed gets a different cache bucket.
var sourcePolicyIdentity = IntegrationRestorePlanResolver.CreateGlobalPackagesFolderIdentity(
[overrideStagingFeed],
[new PackageMapping("Aspire*", overrideStagingFeed)]);
var expectedIdentity = BundleNuGetService.CombineCacheIdentities(
sourcePolicyIdentity,
result.OverlayCacheIdentity!);
var expectedCacheKey = CliPathHelper.ComputeStagingCacheIdentityKey(expectedIdentity);
Assert.NotNull(expectedCacheKey);
var expectedCachePath = Path.Combine(
CliPathHelper.GetStagingNuGetPackagesDirectory(executionContext.AspireHomeDirectory),
expectedCacheKey);
Assert.Equal(expectedCachePath, gpfValue);
}
[Fact]
public async Task CreateRestoreOverlay_ReusedAmbientAliasWithDifferentSourceUrls_UsesDifferentGlobalPackagesFolders()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string sourceAlias = "staging";
const string firstSource = "https://packages.example.com/first/v3/index.json";
const string secondSource = "https://packages.example.com/second/v3/index.json";
var executionContext = CreateContextWithIdentityChannel(PackageChannelNames.Local);
var settingsClient = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
PrebuiltAppHostServer CreateServer(string source)
{
var channel = PackageChannel.CreateExplicitChannel(
name: PackageChannelNames.Staging,
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", source)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
configureGlobalPackagesFolder: true);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var nuGetClient = new FakeNuGetClient
{
GetSettingsCallback = (_, sourceIdentityKey) => CreateNuGetSettingsInfo(
sources: [(sourceAlias, source, true)],
packageSourceMappings: [(sourceAlias, ["Aspire*"])],
sourceIdentityKey: sourceIdentityKey),
WriteNuGetConfigCallback = settingsClient.WriteNuGetConfig
};
return CreatePrebuiltAppHostServer(
workspace,
packagingService: packagingService,
executionContext: executionContext,
nuGetClient: nuGetClient);
}
using var firstResult = await CreateRestoreOverlayAsync(
CreateServer(firstSource),
PackageChannelNames.Staging);
using var secondResult = await CreateRestoreOverlayAsync(
CreateServer(secondSource),
PackageChannelNames.Staging);
Assert.Equal(firstResult.OverlayCacheIdentity, secondResult.OverlayCacheIdentity);
Assert.NotEqual(firstResult.GlobalPackagesFolder, secondResult.GlobalPackagesFolder);
}
[Theory]
[InlineData("local")]
[InlineData("stable")]
[InlineData("daily")]
[InlineData("pr-99")]
public async Task CreateRestoreOverlay_LocalRequested_ReturnsNull_RegardlessOfIdentity(string identity)
{
// The project-requested local channel never emits a mapping overlay, independently of the
// running CLI identity.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel(identity);
var server = CreateServerWithExplicitChannel(workspace, "local", executionContext);
using var result = await CreateRestoreOverlayAsync(server, "local");
Assert.Null(result.PolicyOverlayFile);
}
[Fact]
public void ComposePackageSourceMappings_WithoutAmbientMappings_PreservesAmbientEligibility()
{
var mappings = NuGetConfigurationBuilder.ComposePackageSourceMappings(
[new PackageMapping("Aspire*", "https://example.com/staging")],
ambientMappings: [],
ambientSources:
[
CreateNuGetSourceInfo("private", "https://example.com/private", isEnabled: true),
CreateNuGetSourceInfo("mirror", "https://example.com/mirror", isEnabled: true),
CreateNuGetSourceInfo("disabled", "https://example.com/disabled", isEnabled: false)
],
selectedSources:
[
new NuGetConfigSource(
"aspire-0",
"https://example.com/staging",
IsAmbient: false,
IsEnabled: true)
]);
Assert.Equal(["*"], GetPatterns(mappings, "private"));
Assert.Equal(["*"], GetPatterns(mappings, "mirror"));
Assert.Empty(GetPatterns(mappings, "disabled"));
Assert.Equal(["Aspire*"], GetPatterns(mappings, "aspire-0"));
}
[Fact]
public void ComposePackageSourceMappings_ReplacesCompetingAspireMappingsAndPreservesUnrelatedPolicy()
{
var mappings = NuGetConfigurationBuilder.ComposePackageSourceMappings(
[new PackageMapping("Aspire*", "https://example.com/staging")],
ambientMappings:
[
new NuGetPackageSourceMapping(
"mirror",
["*", "Aspire*", "Aspire.Hosting.*", "Aspire.Hosting.Redis", "Contoso.*"]),
new NuGetPackageSourceMapping("private", ["Microsoft.*"])
],
ambientSources: [],
selectedSources:
[
new NuGetConfigSource(
"staging",
"https://example.com/staging",
IsAmbient: true,
IsEnabled: true)
]);
Assert.Equal(["*", "Contoso.*"], GetPatterns(mappings, "mirror"));
Assert.Equal(["Microsoft.*"], GetPatterns(mappings, "private"));
Assert.Equal(["Aspire*"], GetPatterns(mappings, "staging"));
}
[Fact]
public void ComposePackageSourceMappings_WithExactSourceOverride_PreservesAmbientDependencyMappings()
{
var mappings = NuGetConfigurationBuilder.ComposePackageSourceMappings(
[
new PackageMapping("Aspire.Hosting.Redis", "https://example.com/override"),
new PackageMapping("Aspire*", "https://example.com/override"),
new PackageMapping("*", "https://example.com/override"),
new PackageMapping("*", NuGetOrgSource)
],
ambientMappings:
[
new NuGetPackageSourceMapping("channel", ["Aspire*"]),
new NuGetPackageSourceMapping("private", ["Contoso.*"])
],
ambientSources: [],
selectedSources:
[
new NuGetConfigSource(
"override",
"https://example.com/override",
IsAmbient: false,
IsEnabled: true),
new NuGetConfigSource(
"nuget.org",
NuGetOrgSource,
IsAmbient: false,
IsEnabled: true)
],
packageScopedAppendSource: "https://example.com/override");
Assert.Equal(["Aspire.Hosting.Redis", "Aspire*", "*"], GetPatterns(mappings, "override"));
Assert.Equal(["Aspire*"], GetPatterns(mappings, "channel"));
Assert.Equal(["Contoso.*"], GetPatterns(mappings, "private"));
Assert.Empty(GetPatterns(mappings, "nuget.org"));
}
[Fact]
public void ComposePackageSourceMappings_WithExactSourceOverride_PreservesUnmappedAmbientAspireEligibility()
{
var mappings = NuGetConfigurationBuilder.ComposePackageSourceMappings(
[
new PackageMapping("CommunityToolkit.Aspire.Hosting.Redis", "https://example.com/override"),
new PackageMapping("Aspire*", "https://example.com/override"),
new PackageMapping("*", "https://example.com/override")
],
ambientMappings: [],
ambientSources:
[
CreateNuGetSourceInfo("mirror", "https://example.com/mirror", isEnabled: true),
CreateNuGetSourceInfo("private", "https://example.com/private", isEnabled: true)
],
selectedSources:
[
new NuGetConfigSource(
"override",
"https://example.com/override",
IsAmbient: false,
IsEnabled: true)
],
packageScopedAppendSource: "https://example.com/override");
Assert.Equal(["*", "Aspire*"], GetPatterns(mappings, "mirror"));
Assert.Equal(["*", "Aspire*"], GetPatterns(mappings, "private"));
Assert.Equal(
["CommunityToolkit.Aspire.Hosting.Redis", "Aspire*", "*"],
GetPatterns(mappings, "override"));
}
[Fact]
public void ComposePackageSourceMappings_WithExactSourceMatchingChannel_KeepsChannelAuthoritative()
{
const string source = "https://example.com/channel";
var mappings = NuGetConfigurationBuilder.ComposePackageSourceMappings(
[
new PackageMapping("CommunityToolkit.Aspire.Hosting.Redis", source),
new PackageMapping("Aspire*", source),
new PackageMapping("*", source)
],
ambientMappings:
[
new NuGetPackageSourceMapping("mirror", ["Aspire*", "Contoso.*"])
],
ambientSources: [],
selectedSources:
[
new NuGetConfigSource(
"channel",
source,
IsAmbient: false,
IsEnabled: true)
],
packageScopedAppendSource: source,
hasAuthoritativeAspirePolicy: true);
Assert.Equal(["Contoso.*"], GetPatterns(mappings, "mirror"));
Assert.Equal(
["CommunityToolkit.Aspire.Hosting.Redis", "Aspire*", "*"],
GetPatterns(mappings, "channel"));
}
[Fact]
public void ComposePackageSourceMappings_WithAmbientMappings_DoesNotAddChannelFallbackMapping()
{
var mappings = NuGetConfigurationBuilder.ComposePackageSourceMappings(
[
new PackageMapping("Aspire*", "https://example.com/staging"),
new PackageMapping("*", "https://api.nuget.org/v3/index.json")
],
ambientMappings:
[
new NuGetPackageSourceMapping("mirror", ["*"])
],
ambientSources: [],
selectedSources:
[
new NuGetConfigSource(
"staging",
"https://example.com/staging",
IsAmbient: false,
IsEnabled: true),
new NuGetConfigSource(
"nuget.org",
"https://api.nuget.org/v3/index.json",
IsAmbient: false,
IsEnabled: true)
]);
Assert.Equal(["*"], GetPatterns(mappings, "mirror"));
Assert.Equal(["Aspire*"], GetPatterns(mappings, "staging"));
Assert.Empty(GetPatterns(mappings, "nuget.org"));
}
[Fact]
public void ResolveNuGetConfigSources_UsesWorkloadIdAndAvoidsDormantReservedAdditionalSourceKeys()
{
const string workloadId = "apphost-0123456789abcdef";
var sources = NuGetConfigurationBuilder.ResolveSourceAliases(
[
new PackageMapping("Aspire*", "https://example.com/staging"),
new PackageMapping("Contoso.*", "https://example.com/contoso"),
new PackageMapping("*", "https://example.com/fallback")
],
workloadId,
ambientSources: [],
reservedPackageSourceKeys:
[
$"aspire-{workloadId}-0",
$"aspire-{workloadId}-2"
],
s_sourceIdentityKey);
Assert.Equal(
[
$"aspire-{workloadId}",
$"aspire-{workloadId}-1",
$"aspire-{workloadId}-3"
],
sources.Select(static source => source.Key));
}
[Fact]
public void ResolveNuGetConfigSources_SkipsReservedPrimaryGeneratedSourceKey()
{
const string workloadId = "apphost-0123456789abcdef";
var sources = NuGetConfigurationBuilder.ResolveSourceAliases(
[new PackageMapping("Aspire*", "https://example.com/staging")],
workloadId,
ambientSources: [],
reservedPackageSourceKeys: [$"aspire-{workloadId}"],
s_sourceIdentityKey);
Assert.Equal(
$"aspire-{workloadId}-0",
Assert.Single(sources).Key);
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public void ResolveNuGetConfigSources_PrefersDisabledAliasWithAuthentication(
bool hasCredentials,
bool hasClientCertificates)
{
const string source = "https://example.com/private";
var sources = NuGetConfigurationBuilder.ResolveSourceAliases(
[new PackageMapping("Aspire*", source)],
"apphost-0123456789abcdef",
ambientSources:
[
CreateNuGetSourceInfo("anonymous", source, isEnabled: false),
CreateNuGetSourceInfo(
"authenticated",
source,
isEnabled: false,
hasCredentials,
hasClientCertificates)
],
reservedPackageSourceKeys: ["anonymous", "authenticated"],
s_sourceIdentityKey);
Assert.Equal("authenticated", Assert.Single(sources).Key);
}
[Fact]
public void ResolveNuGetConfigSources_PreservesEnabledAliasOverDisabledAuthenticatedAlias()
{
const string source = "https://example.com/private";
var sources = NuGetConfigurationBuilder.ResolveSourceAliases(
[new PackageMapping("Aspire*", source)],
"apphost-0123456789abcdef",
ambientSources:
[
CreateNuGetSourceInfo("enabled", source, isEnabled: true),
CreateNuGetSourceInfo("authenticated", source, isEnabled: false, hasCredentials: true)
],
reservedPackageSourceKeys: ["enabled", "authenticated"],
s_sourceIdentityKey);
Assert.Equal("enabled", Assert.Single(sources).Key);
}
[Fact]
public void CreateNuGetConfigOverlay_EnablesSelectedAliasWithoutReEmittingCaseVariantDisabledKey()
{
const string source = "https://example.com/staging";
var settings = new NuGetSettingsInfo(
ConfigPaths: [],
CacheIdentity: "settings",
Sources:
[
CreateNuGetSourceInfo("Private", source, isEnabled: false),
CreateNuGetSourceInfo("unrelated", "https://example.com/unrelated", isEnabled: false)
],
SensitiveSourceValues: [],
PackageSourceMappings: [],
DisabledPackageSourceKeys: ["private", "unrelated"],
ReservedPackageSourceKeys: ["Private", "unrelated"],
SourceIdentityKey: s_sourceIdentityKey);
var overlay = NuGetConfigurationBuilder.ComposeOverlay(
[new PackageMapping("Aspire*", source)],
settings,
[new NuGetConfigSource("Private", source, IsAmbient: true, IsEnabled: false)],
restrictToSelectedSources: false,
globalPackagesFolder: null);
Assert.True(overlay.ClearDisabledPackageSources);
Assert.Equal(["unrelated"], overlay.DisabledPackageSourceKeys);
Assert.Equal(["Aspire*"], GetPatterns(overlay.PackageSourceMappings, "Private"));
}
[Fact]
public async Task CreateRestoreOverlay_WithPackageSourceOverride_MapsAspireToOverrideWithoutAddingNuGetOrg()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-packages");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([])
};
var server = CreateServerWithPackagingService(workspace, packagingService);
using var result = await CreateRestoreOverlayAsync(
server,
requestedChannel: null,
packageSourceOverride: packageSourceOverride);
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride));
Assert.Empty(GetPackagePatternsForSource(doc, NuGetOrgSource));
}
[Fact]
public async Task CreateRestoreOverlay_WithPackageSourceOverrideWithoutRequestedChannel_DoesNotIncludeExplicitChannelAspireMappings()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
const string identityChannel = "pr-12345";
var explicitChannel = PackageChannel.CreateExplicitChannel(
name: identityChannel,
quality: PackageChannelQuality.Both,
mappings:
[
new PackageMapping("Aspire*", channelSource),
new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)
],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var server = CreateServerWithChannel(
workspace,
explicitChannel,
CreateContextWithIdentityChannel(identityChannel, "13.4.0"));
using var result = await CreateRestoreOverlayAsync(
server,
requestedChannel: null,
packageSourceOverride: packageSourceOverride);
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride));
Assert.Empty(GetPackagePatternsForSource(doc, channelSource));
Assert.Empty(GetPackagePatternsForSource(doc, NuGetOrgSource));
}
[Fact]
public async Task CreateRestoreOverlay_WithPackageSourceOverride_PreservesRequestedChannelMappingsAndGlobalPackagesFolder()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var stagingChannel = PackageChannel.CreateExplicitChannel(
name: "staging",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("CommunityToolkit*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
configureGlobalPackagesFolder: true);
var executionContext = CreateContextWithIdentityChannel("pr-12345");
var server = CreateServerWithChannel(workspace, stagingChannel, executionContext);
using var result = await CreateRestoreOverlayAsync(
server,
requestedChannel: "staging",
packageSourceOverride: packageSourceOverride);
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride));
Assert.Equal(["CommunityToolkit*"], GetPackagePatternsForSource(doc, channelSource));
Assert.Empty(GetPackagePatternsForSource(doc, NuGetOrgSource));
var gpf = doc.Descendants("config")
.SelectMany(c => c.Elements("add"))
.FirstOrDefault(a => string.Equals(a.Attribute("key")?.Value, "globalPackagesFolder", StringComparison.OrdinalIgnoreCase));
Assert.NotNull(gpf);
// The source-specific package cache must outlive the policy overlay so BundleNuGetService's
// manifest paths remain valid after disposal.
var gpfValue = gpf.Attribute("value")?.Value;
Assert.False(string.IsNullOrEmpty(gpfValue));
Assert.True(Path.IsPathFullyQualified(gpfValue), $"globalPackagesFolder value must be an absolute path. Got: {gpfValue}");
var overlayDirectory = overlayFile.Directory!.FullName;
Assert.False(
gpfValue!.StartsWith(overlayDirectory, StringComparison.Ordinal),
$"globalPackagesFolder must not be under the policy overlay directory '{overlayDirectory}'. Got: {gpfValue}");
// The cache key includes both the --source override and the retained channel mappings,
// so any selected source-policy change gets a distinct cache bucket.
var sourcePolicyIdentity = IntegrationRestorePlanResolver.CreateGlobalPackagesFolderIdentity(
[packageSourceOverride, channelSource],
[
new PackageMapping("Aspire*", packageSourceOverride),
new PackageMapping("CommunityToolkit*", channelSource)
]);
var expectedIdentity = BundleNuGetService.CombineCacheIdentities(
sourcePolicyIdentity,
result.OverlayCacheIdentity!);
var expectedCacheKey = CliPathHelper.ComputeStagingCacheIdentityKey(expectedIdentity);
Assert.NotNull(expectedCacheKey);
var expectedCachePath = Path.Combine(
CliPathHelper.GetStagingNuGetPackagesDirectory(executionContext.AspireHomeDirectory),
expectedCacheKey);
Assert.Equal(expectedCachePath, gpfValue);
}
[Fact]
public async Task CreateRestoreOverlay_WithPackageSourceOverride_DropsRequestedChannelAspireMappings()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var stagingChannel = PackageChannel.CreateExplicitChannel(
name: "staging",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", channelSource), new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345"));
using var result = await CreateRestoreOverlayAsync(
server,
requestedChannel: "staging",
packageSourceOverride: packageSourceOverride);
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride));
Assert.Empty(GetPackagePatternsForSource(doc, channelSource));
Assert.Empty(GetPackagePatternsForSource(doc, NuGetOrgSource));
}
[Theory]
[InlineData("Aspire.Hosting.Redis")]
[InlineData("CommunityToolkit.Aspire.Hosting.Redis")]
public async Task CreateRestoreOverlay_WithExactPackageSourceOverride_PreservesChannelAspireMappings(
string packageId)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "integration-packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var stagingChannel = PackageChannel.CreateExplicitChannel(
name: "staging",
quality: PackageChannelQuality.Both,
mappings:
[
new PackageMapping("Aspire*", channelSource),
new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)
],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var server = CreateServerWithChannel(
workspace,
stagingChannel,
CreateContextWithIdentityChannel("pr-12345"));
using var result = await CreateRestoreOverlayAsync(
server,
requestedChannel: "staging",
packageSourceOverride: packageSourceOverride,
packageSourceOverridePattern: packageId);
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
Assert.Equal(
[packageId, PackageSourceOverrideMappings.DefaultPackagePattern, PackageMapping.AllPackages],
GetPackagePatternsForSource(doc, packageSourceOverride));
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, channelSource));
Assert.Empty(GetPackagePatternsForSource(doc, NuGetOrgSource));
}
[Fact]
public async Task CreateRestoreOverlay_WithExactPackageSourceOverride_AppendsMatchingIdentityAspireSource()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string identityChannel = "pr-12345";
const string identityVersion = "13.4.0";
const string packageId = "CommunityToolkit.Aspire.Hosting.Redis";
var packageSourceOverride = workspace.CreateDirectory("integration-packages");
var identityPackageSource = workspace.CreateDirectory("identity-packages");
var channel = PackageChannel.CreateExplicitChannel(
name: identityChannel,
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", identityPackageSource.FullName)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var server = CreateServerWithChannel(
workspace,
channel,
CreateContextWithIdentityChannel(identityChannel, identityVersion));
using var result = await CreateRestoreOverlayAsync(
server,
requestedChannel: null,
packageSourceOverride: packageSourceOverride.FullName,
packageSourceOverridePattern: packageId);
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
Assert.Equal(
[packageId, PackageSourceOverrideMappings.DefaultPackagePattern, PackageMapping.AllPackages],
GetPackagePatternsForSource(doc, packageSourceOverride.FullName));
Assert.Equal(
[PackageSourceOverrideMappings.DefaultPackagePattern],
GetPackagePatternsForSource(doc, identityPackageSource.FullName));
}
[Fact]
public async Task CreateRestoreOverlay_WithUnknownRequestedChannel_Throws()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string packageSourceOverride = "/tmp/aspire-packages";
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([])
};
var server = CreateServerWithPackagingService(workspace, packagingService);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
CreateRestoreOverlayAsync(
server,
requestedChannel: PackageChannelNames.Staging,
packageSourceOverride: packageSourceOverride));
Assert.Contains(PackageChannelNames.Staging, exception.Message);
Assert.Equal(PackageChannelNames.Staging, packagingService.LastRequestedChannelName);
}
[Fact]
public async Task CreateRestoreOverlay_WithPackageSourceOverride_IgnoresChannelAllPackagesFallback()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var stagingChannel = PackageChannel.CreateExplicitChannel(
name: "staging",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping(PackageMapping.AllPackages, channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345"));
using var result = await CreateRestoreOverlayAsync(
server,
requestedChannel: "staging",
packageSourceOverride: packageSourceOverride);
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride));
Assert.Empty(GetPackagePatternsForSource(doc, channelSource));
Assert.Empty(GetPackagePatternsForSource(doc, NuGetOrgSource));
}
[Fact]
public async Task CreateRestoreOverlay_WithPackageSourceOverride_WhenChannelLookupFails_StillCreatesOverridePolicy()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-packages");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => throw new InvalidOperationException("Channel lookup failed.")
};
var server = CreateServerWithPackagingService(workspace, packagingService);
using var result = await CreateRestoreOverlayAsync(
server,
requestedChannel: "staging",
packageSourceOverride: packageSourceOverride);
var overlayFile = Assert.IsType<FileInfo>(result.PolicyOverlayFile);
var doc = XDocument.Load(overlayFile.FullName);
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, packageSourceOverride));
Assert.Empty(GetPackagePatternsForSource(doc, NuGetOrgSource));
}
[Fact]
public async Task CreateRestoreOverlay_WithExplicitChannelAndNoOverride_WhenChannelLookupFails_Throws()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => throw new InvalidOperationException("Channel lookup failed.")
};
var server = CreateServerWithPackagingService(workspace, packagingService);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
CreateRestoreOverlayAsync(
server,
requestedChannel: "staging",
packageSourceOverride: null));
Assert.Equal("Channel lookup failed.", exception.Message);
}
[Fact]
public async Task ResolveAdditionalSources_WithPackageSourceOverrideAndMatchedChannel_OmitsChannelAspireFeedFromSources()
{
// Additional sources are co-eligible with mapped sources, so the channel's Aspire feed
// must be excluded when an explicit source owns the Aspire package mapping.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var stagingChannel = PackageChannel.CreateExplicitChannel(
name: "staging",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345"));
var sources = await ResolveAdditionalSourcesAsync(server, requestedChannel: "staging", packageSourceOverride: packageSourceOverride);
Assert.NotNull(sources);
Assert.Contains(packageSourceOverride, sources);
Assert.DoesNotContain(channelSource, sources);
Assert.DoesNotContain(NuGetOrgSource, sources);
}
[Fact]
public async Task ResolveAdditionalSources_WithPackageSourceOverrideAndMatchedChannelNonAspireMapping_KeepsChannelSourceWithoutAddingNuGetOrg()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var stagingChannel = PackageChannel.CreateExplicitChannel(
name: "staging",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("CommunityToolkit*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345"));
var sources = await ResolveAdditionalSourcesAsync(server, requestedChannel: "staging", packageSourceOverride: packageSourceOverride);
Assert.NotNull(sources);
Assert.Contains(packageSourceOverride, sources);
Assert.Contains(channelSource, sources);
Assert.DoesNotContain(NuGetOrgSource, sources);
}
[Fact]
public async Task ResolveAdditionalSources_WithPackageSourceOverrideAndMatchedChannelAllPackagesMapping_IgnoresChannelFallback()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var stagingChannel = PackageChannel.CreateExplicitChannel(
name: "staging",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping(PackageMapping.AllPackages, channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var server = CreateServerWithChannel(workspace, stagingChannel, CreateContextWithIdentityChannel("pr-12345"));
var sources = await ResolveAdditionalSourcesAsync(server, requestedChannel: "staging", packageSourceOverride: packageSourceOverride);
Assert.NotNull(sources);
Assert.Contains(packageSourceOverride, sources);
Assert.DoesNotContain(channelSource, sources);
Assert.DoesNotContain(NuGetOrgSource, sources);
}
[Theory]
[InlineData("https://api.nuget.org/v3/index.json", "https://api.nuget.org/v3/index.json")]
[InlineData("/tmp/aspire-packages", "/tmp/aspire-packages")]
[InlineData(@"C:\packages", @"C:\packages")]
[InlineData("https://user:pat@feed.example.com/v3/index.json", "https://***@feed.example.com/v3/index.json")]
[InlineData("https://feed.blob.core.windows.net/foo/index.json?sv=2024-01&sig=secret-sig", "https://feed.blob.core.windows.net/foo/index.json")]
[InlineData("https://user:pat@feed.example.com/v3/index.json?sig=secret", "https://***@feed.example.com/v3/index.json")]
[InlineData("https://feed.example.com/v3/index.json#fragment", "https://feed.example.com/v3/index.json")]
public void RedactSourceForDisplay_StripsCredentialsAndQueryFromHttpUrlsButPreservesPlainSources(string input, string expected)
{
Assert.Equal(expected, PrebuiltAppHostServer.RedactSourceForDisplay(input));
}
[Fact]
public async Task CreateRestoreOverlay_StagingRequested_RefusesWhenPackagingServiceReportsUnavailable()
{
// An unavailable staging channel must fail with the packaging service's actionable reason
// instead of allowing restore to use another explicit channel.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("daily");
const string unavailableReason =
"Staging unavailable on this daily CLI build. Set overrideStagingFeed or enable the StagingChannelEnabled feature flag to use it.";
var server = CreateServerWithUnavailableStagingChannel(workspace, executionContext, unavailableReason);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateRestoreOverlayAsync(server, "staging"));
Assert.Equal(unavailableReason, ex.Message);
}
[Fact]
public async Task CreateRestoreOverlay_StagingRequestedWithSourceOverride_RefusesWhenPackagingServiceReportsUnavailable()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("daily");
const string unavailableReason =
"Staging unavailable on this daily CLI build. Set overrideStagingFeed or enable the StagingChannelEnabled feature flag to use it.";
var server = CreateServerWithUnavailableStagingChannel(workspace, executionContext, unavailableReason);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateRestoreOverlayAsync(server, "staging", "/tmp/aspire-pr-hive/packages"));
Assert.Equal(unavailableReason, ex.Message);
}
[Fact]
public async Task ResolveAdditionalSources_StagingRequested_RefusesWhenPackagingServiceReportsUnavailable()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("daily");
const string unavailableReason =
"Staging unavailable on this daily CLI build. Set overrideStagingFeed or enable the StagingChannelEnabled feature flag to use it.";
var server = CreateServerWithUnavailableStagingChannel(workspace, executionContext, unavailableReason);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => ResolveAdditionalSourcesAsync(server, "staging"));
Assert.Equal(unavailableReason, ex.Message);
}
[Fact]
public async Task ResolveAdditionalSources_NonStagingRequest_NotAffectedByStagingUnavailableReason()
{
// Negative control: the staging refusal must only fire for requestedChannel == "staging".
// A request for any other channel name must continue to resolve normally even when the
// packaging service is reporting staging-unavailable.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = CreateContextWithIdentityChannel("daily");
var mappings = new[]
{
new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/fake/v3/index.json")
};
var dailyChannel = PackageChannel.CreateExplicitChannel(
"daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]),
GetStagingChannelUnavailableReasonCallback = () => "Staging unavailable"
};
var server = CreatePrebuiltAppHostServer(workspace, packagingService: packagingService, executionContext: executionContext);
var sources = await ResolveAdditionalSourcesAsync(server, "daily");
Assert.NotNull(sources);
Assert.Contains("https://pkgs.dev.azure.com/fake/v3/index.json", sources);
}
private static PrebuiltAppHostServer CreateServerWithUnavailableStagingChannel(
TemporaryWorkspace workspace,
CliExecutionContext executionContext,
string unavailableReason)
{
// PackagingService omits an unavailable staging channel and reports the reason separately.
// Returning a daily channel verifies that source resolution does not substitute it.
var mappings = new[]
{
new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/fake/v3/index.json")
};
var dailyChannel = PackageChannel.CreateExplicitChannel(
"daily", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel]),
GetStagingChannelUnavailableReasonCallback = () => unavailableReason
};
return CreatePrebuiltAppHostServer(workspace, packagingService: packagingService, executionContext: executionContext);
}
private static CliExecutionContext CreateContextWithIdentityChannel(
string identityChannel,
string? identityVersion = null) =>
new(new DirectoryInfo(Path.GetTempPath()),
new DirectoryInfo(Path.Combine(Path.GetTempPath(), "hives")),
new DirectoryInfo(Path.Combine(Path.GetTempPath(), "cache")),
new DirectoryInfo(Path.Combine(Path.GetTempPath(), "sdks")),
new DirectoryInfo(Path.Combine(Path.GetTempPath(), "logs")),
"test.log",
identityChannel: identityChannel,
identityVersion: identityVersion);
// Builds a PrebuiltAppHostServer with the constant test wiring (socket name, SDK installer, process
// execution factory, logger) so individual tests only specify the parameters their scenario exercises.
// The execution context defaults to a fresh test context and is shared with the default NuGet service.
// Tests that need bundle-layout discovery (FixedLayoutDiscovery) or a custom process runner build their
// own BundleNuGetService and pass it via nugetService.
private static PrebuiltAppHostServer CreatePrebuiltAppHostServer(
TemporaryWorkspace workspace,
string? appPath = null,
LayoutConfiguration? layout = null,
TestDotNetCliRunner? dotNetCliRunner = null,
IPackagingService? packagingService = null,
CliExecutionContext? executionContext = null,
FakeNuGetClient? nuGetClient = null)
{
executionContext ??= TestExecutionContextFactory.CreateTestContext();
nuGetClient ??= CreateIsolatedNuGetClient();
var nugetService = new BundleNuGetService(
NullLogger<BundleNuGetService>.Instance,
nuGetClient)
{
SourceIdentityKeyFactory = static () => s_sourceIdentityKey
};
return new PrebuiltAppHostServer(
appPath ?? workspace.WorkspaceRoot.FullName,
"test.sock",
layout ?? new LayoutConfiguration(),
nugetService,
dotNetCliRunner ?? new TestDotNetCliRunner(),
new TestDotNetSdkInstaller(),
packagingService ?? MockPackagingServiceFactory.Create(),
executionContext,
new TestProcessExecutionFactory(),
new TestEnvironment(),
NullLogger.Instance);
}
private static PrebuiltAppHostServer CreateServerWithExplicitChannel(
TemporaryWorkspace workspace,
string channelName,
CliExecutionContext executionContext)
{
// channelName is the name of the channel registered in the TestPackagingService — i.e. the
// channel a project's aspire.config.json would resolve to when it requests that name.
var mappings = new[]
{
new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/fake/v3/index.json")
};
var channel = PackageChannel.CreateExplicitChannel(
channelName, PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
return CreateServerWithChannel(workspace, channel, executionContext);
}
private static PrebuiltAppHostServer CreateServerWithChannel(
TemporaryWorkspace workspace,
PackageChannel channel,
CliExecutionContext executionContext)
{
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
return CreateServerWithPackagingService(workspace, packagingService, executionContext);
}
private static PrebuiltAppHostServer CreateServerWithPackagingService(
TemporaryWorkspace workspace,
IPackagingService packagingService,
CliExecutionContext? executionContext = null)
{
return CreatePrebuiltAppHostServer(workspace, packagingService: packagingService, executionContext: executionContext);
}
private static async Task<IntegrationPackageRestoreConfiguration> CreateRestoreOverlayAsync(
PrebuiltAppHostServer server,
string? requestedChannel,
string? packageSourceOverride = null,
string? packageSourceOverridePattern = null)
{
var restorePlan = await server.ResolveIntegrationRestorePlanAsync(
"13.4.0",
requestedChannel,
packageSourceOverride,
packageSourceOverridePattern,
CancellationToken.None);
return await restorePlan.CreatePackageRestoreConfigurationAsync(CancellationToken.None);
}
private static NuGetSourceInfo CreateNuGetSourceInfo(
string name,
string source,
bool isEnabled,
bool hasCredentials = false,
bool hasClientCertificates = false)
=> new(
name,
NuGetSourceIdentity.Compute(source, s_sourceIdentityKey),
isEnabled,
hasCredentials,
hasClientCertificates);
private static async Task<IReadOnlyList<string>?> ResolveAdditionalSourcesAsync(
PrebuiltAppHostServer server,
string? requestedChannel,
string? packageSourceOverride = null,
string? packageSourceOverridePattern = null)
{
var restorePlan = await server.ResolveIntegrationRestorePlanAsync(
"13.4.0",
requestedChannel,
packageSourceOverride,
packageSourceOverridePattern,
CancellationToken.None);
return restorePlan.AdditionalSources.Count > 0
? restorePlan.AdditionalSources
: null;
}
[Fact]
public async Task ResolveRequestedChannel_UsesProjectLocalAspireConfig()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "pr-new"
}
""");
var server = CreatePrebuiltAppHostServer(workspace);
var channel = server.ResolveRequestedChannel();
Assert.Equal("pr-new", channel);
}
[Fact]
public async Task PrepareAsync_WithNoIntegrations_WritesDefaultAppSettings()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var server = CreatePrebuiltAppHostServer(workspace);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync("13.2.0", []);
Assert.True(
result.Success,
result.Output is null
? null
: string.Join(Environment.NewLine, result.Output.GetLines().Select(static line => line.Line)));
Assert.Null(server.SelectedProjectLayoutPath);
var appSettingsPath = Path.Combine(workingDirectory, "appsettings.json");
Assert.True(File.Exists(appSettingsPath));
var appSettingsContent = await File.ReadAllTextAsync(appSettingsPath);
Assert.Contains("\"Aspire.Hosting\"", appSettingsContent);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithPackageReferences_SetsOnlyPackageProbeManifest()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.2.0",
[IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0")]);
Assert.True(result.Success);
Assert.Null(server.SelectedProjectLayoutPath);
Assert.Equal(1, nuGetClient.RestoreCallCount);
Assert.Equal(1, nuGetClient.WriteManifestCallCount);
var manifestPath = Assert.IsType<string>(server.IntegrationProbeManifestPath);
Assert.StartsWith(
CliPathHelper.StripMacOSFirmlinkPrefix(
Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "integrations", "package-restore")),
CliPathHelper.StripMacOSFirmlinkPrefix(manifestPath),
StringComparison.OrdinalIgnoreCase);
var startInfo = server.CreateStartInfo(123);
Assert.Equal(manifestPath, startInfo.Environment[KnownConfigNames.IntegrationProbeManifestPath]);
Assert.False(startInfo.Environment.ContainsKey(KnownConfigNames.IntegrationLibsPath));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WhenPackageRestoreIsCanceled_PropagatesCancellation()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
using var cancellation = new CancellationTokenSource();
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated);
nuGetClient.RestoreCallback = (_, _, _, _, _, _, _, _, _, cancellationToken) =>
{
cancellation.Cancel();
return Task.FromCanceled(cancellationToken);
};
var workingDirectory = GetWorkingDirectory(server);
try
{
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => server.PrepareAsync(
"13.2.0",
[IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0")],
cancellationToken: cancellation.Token));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithPackageReferences_UsesPackageSourceOverride()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = workspace.CreateDirectory(
Path.Combine("aspire-pr-hive", "packages")).FullName;
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[
IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"),
IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0")
],
packageSourceOverride: packageSourceOverride);
Assert.True(result.Success);
Assert.Equal([packageSourceOverride], GetConfiguredPackageSources(nuGetClient));
Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!);
Assert.Contains(("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0"), nuGetClient.LastRestorePackages!);
Assert.NotEmpty(nuGetClient.LastNuGetConfigPaths!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithExactPackageSourceOverridePattern_PinsOnlyMatchingPackage()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = workspace.CreateDirectory("integration-packages").FullName;
const string selectedPackage = "CommunityToolkit.Aspire.Hosting.Redis";
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromPackage(selectedPackage, "1.0.0")
],
packageSourceOverride: packageSourceOverride,
packageSourceOverridePattern: selectedPackage);
Assert.True(result.Success);
Assert.Contains(("Aspire.Hosting.Redis", "13.4.0"), nuGetClient.LastRestorePackages!);
Assert.Contains((selectedPackage, "[1.0.0]"), nuGetClient.LastRestorePackages!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithPackageSourceOverride_DoesNotAddNuGetOrgFallbackSource()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = workspace.CreateDirectory(
Path.Combine("aspire-pr-hive", "packages")).FullName;
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[
IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"),
IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0")
],
packageSourceOverride: packageSourceOverride);
Assert.True(result.Success);
Assert.Equal([packageSourceOverride], GetConfiguredPackageSources(nuGetClient));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Theory]
[InlineData("pr-12345")]
[InlineData("local")]
[InlineData("worktree-feature")]
public async Task PrepareAsync_WithHiveBackedChannel_UsesLocalAspireSourceAsOverride(string channelName)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSource = workspace.CreateDirectory("hive-packages");
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, $$"""
{
"channel": "{{channelName}}"
}
""");
var channel = PackageChannel.CreateExplicitChannel(
name: channelName,
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", packageSource.FullName)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated, packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[
IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"),
IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0")
]);
Assert.True(result.Success);
Assert.Equal([packageSource.FullName], GetConfiguredPackageSources(nuGetClient));
Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!);
Assert.Contains(("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0"), nuGetClient.LastRestorePackages!);
Assert.NotEmpty(nuGetClient.LastNuGetConfigPaths!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithHiveBackedChannelUsingFileUri_UsesLocalAspireSourceAsOverride()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSource = workspace.CreateDirectory("hive-packages");
var packageSourceUri = new Uri(packageSource.FullName).AbsoluteUri;
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "pr-12345"
}
""");
var channel = PackageChannel.CreateExplicitChannel(
name: "pr-12345",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", packageSourceUri)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated, packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[
IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"),
IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0")
]);
Assert.True(result.Success);
Assert.Equal([packageSourceUri], GetConfiguredPackageSources(nuGetClient));
Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!);
Assert.Contains(("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0"), nuGetClient.LastRestorePackages!);
Assert.NotEmpty(nuGetClient.LastNuGetConfigPaths!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithExplicitPackageSourceOverride_IgnoresHiveBackedAspireSource()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var explicitPackageSource = workspace.CreateDirectory("explicit-packages");
var hivePackageSource = workspace.CreateDirectory("hive-packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "pr-12345"
}
""");
var channel = PackageChannel.CreateExplicitChannel(
name: "pr-12345",
quality: PackageChannelQuality.Both,
mappings:
[
new PackageMapping("Aspire*", hivePackageSource.FullName),
new PackageMapping(PackageMapping.AllPackages, channelSource)
],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated, packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")],
packageSourceOverride: explicitPackageSource.FullName);
Assert.True(result.Success);
Assert.Equal([explicitPackageSource.FullName], GetConfiguredPackageSources(nuGetClient));
Assert.DoesNotContain(hivePackageSource.FullName, GetConfiguredPackageSources(nuGetClient));
Assert.DoesNotContain(channelSource, GetConfiguredPackageSources(nuGetClient));
Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithHttpBackedChannel_DoesNotUseExactPackageVersions()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "daily"
}
""");
var channel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated, packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]);
Assert.True(result.Success);
Assert.Equal([channelSource], GetConfiguredPackageSources(nuGetClient));
Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"), nuGetClient.LastRestorePackages!);
Assert.DoesNotContain(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WhenPackagingServiceThrowsForExplicitChannel_Fails()
{
// An explicitly requested channel must not silently fall back to ambient NuGet sources
// when channel resolution fails, because that could restore a different package build
// while reporting the requested channel.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string channelName = "pr-12345";
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, $$"""
{
"channel": "{{channelName}}"
}
""");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromException<IEnumerable<PackageChannel>>(
new InvalidOperationException("simulated packaging service failure"))
};
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated, packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]);
Assert.False(result.Success);
Assert.Null(nuGetClient.LastRestorePackages);
Assert.Contains(
result.Output!.GetLines(),
line => line.Line.Contains("simulated packaging service failure", StringComparison.Ordinal));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithHiveBackedChannelPointingAtMissingLocalDirectory_DoesNotApplyOverride()
{
// A stale aspire.config.json (e.g. channel = "pr-12345" after its local hive was deleted)
// must not pin Aspire packages to a non-existent local directory. Local-source discovery
// skips missing directories, so restore falls through to the channel source without an
// invocation-local override or exact version pin.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var missingPackageSource = Path.Combine(workspace.WorkspaceRoot.FullName, "this-hive-was-deleted");
Assert.False(Directory.Exists(missingPackageSource));
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "pr-12345"
}
""");
var channel = PackageChannel.CreateExplicitChannel(
name: "pr-12345",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", missingPackageSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated, packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]);
Assert.True(result.Success);
Assert.NotNull(nuGetClient.LastRestorePackages);
// The override was not applied (Directory.Exists check failed), so the source list
// is just the channel's raw Aspire mapping and no exact-pin is emitted. Contrast with
// PrepareAsync_WithHiveBackedChannel_UsesLocalAspireSourceAsOverride where the
// existing local directory promotes the channel source to an override and exact-pins it.
Assert.Equal([missingPackageSource], GetConfiguredPackageSources(nuGetClient));
Assert.DoesNotContain(NuGetOrgSource, GetConfiguredPackageSources(nuGetClient));
Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"), nuGetClient.LastRestorePackages);
Assert.DoesNotContain(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithSourceAndChannelHavingAspireMapping_PolicyOverlayDropsChannelAspireMapping()
{
// End-to-end check that `aspire new --source <pr> --channel <X>` does not let the channel's
// Aspire* feed remain co-eligible with the override at restore time.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(
workspace.WorkspaceRoot.FullName,
"aspire-pr-hive",
"packages");
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "daily"
}
""");
var dailyChannel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings:
[
new PackageMapping("Aspire*", channelSource),
new PackageMapping(PackageMapping.AllPackages, NuGetOrgSource)
],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel])
};
XDocument? policyOverlay = null;
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated, packagingService);
nuGetClient.RestoreCallback = (_, _, _, _, _, configPaths, _, _, _, _) =>
{
// Read the temp NuGet.config while it still exists; PrepareAsync deletes it
// after the in-process restore call returns.
policyOverlay = XDocument.Load(configPaths[0]);
return Task.CompletedTask;
};
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")],
packageSourceOverride: packageSourceOverride);
Assert.True(result.Success);
Assert.Equal("daily", result.ChannelName);
Assert.NotNull(policyOverlay);
// The overlay's complete mapping policy makes only the override eligible for Aspire packages.
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(policyOverlay!, packageSourceOverride));
Assert.Empty(GetPackagePatternsForSource(policyOverlay!, channelSource));
Assert.Empty(GetPackagePatternsForSource(policyOverlay!, NuGetOrgSource));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_RestoreFailure_OutputIncludesSourceAndChannelContext()
{
// When restore fails, the displayed output is the only debugging surface most users see.
// Pin that --source and the requested channel are present so a failed
// `aspire new --source <X> --channel <Y>` doesn't require re-running with diagnostic logs
// just to recover which inputs were in play.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string packageSourceOverride = "/tmp/aspire-pr-hive/packages";
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "daily"
}
""");
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated);
nuGetClient.RestoreCallback = (_, _, _, _, _, _, _, _, _, _) =>
Task.FromException(new InvalidOperationException("simulated restore failure"));
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")],
packageSourceOverride: packageSourceOverride);
Assert.False(result.Success);
Assert.NotNull(result.Output);
var combined = string.Join('\n', result.Output!.GetLines().Select(static line => line.Line));
Assert.Contains($"--source: {packageSourceOverride}", combined);
Assert.Contains("channel: daily", combined);
Assert.Contains("packages: Aspire.Hosting.CodeGeneration.TypeScript 13.4.0-pr.17141.gf142085f", combined);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_RestoreFailure_WithManyPackages_TruncatesPackageList()
{
// The package preview caps at 5 entries with a "(+N more)" suffix so the error footer
// doesn't explode for projects with large package counts. Pin the truncation shape so
// it can't silently regress.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string packageSourceOverride = "/tmp/aspire-pr-hive/packages";
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated);
nuGetClient.RestoreCallback = (_, _, _, _, _, _, _, _, _, _) =>
Task.FromException(new InvalidOperationException("simulated restore failure"));
var packages = Enumerable.Range(0, 8)
.Select(i => IntegrationReference.FromPackage($"Aspire.Hosting.Pkg{i}", "1.0.0"))
.ToArray();
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
packages,
packageSourceOverride: packageSourceOverride);
Assert.False(result.Success);
Assert.NotNull(result.Output);
var combined = string.Join('\n', result.Output!.GetLines().Select(static line => line.Line));
// First five packages appear; later ones are collapsed into a count.
Assert.Contains("Aspire.Hosting.Pkg0 1.0.0", combined);
Assert.Contains("Aspire.Hosting.Pkg4 1.0.0", combined);
Assert.DoesNotContain("Aspire.Hosting.Pkg5 1.0.0", combined);
Assert.DoesNotContain("Aspire.Hosting.Pkg7 1.0.0", combined);
Assert.Contains("(+3 more)", combined);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithProjectReferencesAndNoRequestedChannel_UsesAmbientSourcePolicy()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
XDocument? generatedProject = null;
var policyConfigPath = Path.Combine(
workspace.WorkspaceRoot.FullName,
AspireJsonConfiguration.SettingsFolder,
"NuGet.Config");
Directory.CreateDirectory(Path.GetDirectoryName(policyConfigPath)!);
await File.WriteAllTextAsync(policyConfigPath, "<configuration />");
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, _, _) =>
{
generatedProject = XDocument.Load(projectFilePath.FullName);
WriteClosureInputs(
projectFilePath.Directory!,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["MyIntegration.dll"] = "integration-v1"
},
["MyIntegration"]);
return 0;
}
};
var channel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
logger: NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var server = CreatePrebuiltAppHostServer(
workspace,
dotNetCliRunner: dotNetCliRunner,
packagingService: packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17141.gf142085f"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
]);
Assert.True(result.Success);
Assert.NotNull(generatedProject);
var ns = generatedProject.Root!.GetDefaultNamespace();
Assert.Equal(
string.Empty,
generatedProject.Descendants(ns + "RestoreAdditionalProjectSources").Single().Value);
Assert.False(File.Exists(Path.Combine(workingDirectory, "integration-restore", "NuGet.Config")));
Assert.False(File.Exists(policyConfigPath));
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithProjectReferencesAndExplicitChannel_UsesDiscoveredConfigAndPersistentPolicyOverlay()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var noRestoreValues = new List<bool>();
var processOptions = new List<ProcessInvocationOptions>();
XDocument? generatedProject = null;
XDocument? generatedPolicyOverlay = null;
string? nugetConfigDiscoveryDirectory = null;
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "daily"
}
""");
var ambientConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
await File.WriteAllTextAsync(ambientConfigPath, $$"""
<configuration>
<packageSources>
<add key="anonymousAlias" value="{{channelSource}}" />
<add key="private" value="{{channelSource}}" />
<add key="unrelated" value="https://example.com/unrelated" />
</packageSources>
<disabledPackageSources>
<add key="anonymousAlias" value="true" />
<add key="private" value="true" />
<add key="unrelated" value="true" />
</disabledPackageSources>
<packageSourceCredentials>
<private>
<add key="Username" value="user" />
<add key="ClearTextPassword" value="secret" />
</private>
</packageSourceCredentials>
</configuration>
""");
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MyIntegration.dll"] = "integration-v1"
};
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, noRestore, options, _) =>
{
noRestoreValues.Add(noRestore);
processOptions.Add(options);
generatedProject = XDocument.Load(projectFilePath.FullName);
generatedPolicyOverlay = LoadGeneratedPolicyOverlay(projectFilePath);
WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]);
return 0;
}
};
var dailyChannel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
logger: NullLogger.Instance,
configureGlobalPackagesFolder: true);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel])
};
var layout = CreateBundleLayout(workspace);
var settingsClient = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var nuGetClient = new FakeNuGetClient
{
GetSettingsCallback = (workingDirectory, sourceIdentityKey) =>
{
nugetConfigDiscoveryDirectory = workingDirectory;
return settingsClient.GetSettings(workingDirectory, sourceIdentityKey);
},
WriteNuGetConfigCallback = settingsClient.WriteNuGetConfig
};
var server = CreatePrebuiltAppHostServer(
workspace,
layout: layout,
dotNetCliRunner: dotNetCliRunner,
packagingService: packagingService,
nuGetClient: nuGetClient);
var workingDirectory = GetWorkingDirectory(server);
try
{
var firstResult = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17141.gf142085f"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
]);
var secondResult = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17141.gf142085f"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
]);
Assert.True(firstResult.Success);
Assert.True(secondResult.Success);
Assert.Equal([false, false], noRestoreValues);
Assert.All(processOptions, options =>
{
Assert.True(options.SuppressLogging);
Assert.NotNull(options.EnvironmentVariableFilter);
Assert.True(options.EnvironmentVariableFilter(CliPathHelper.NuGetPackagesEnvironmentVariable));
Assert.True(options.EnvironmentVariableFilter(PrebuiltAppHostServer.IntegrationHostingVersionPropertyName));
Assert.True(options.EnvironmentVariableFilter(PrebuiltAppHostServer.IntegrationPackageSourcesPropertyName));
Assert.True(options.EnvironmentVariableFilter(PrebuiltAppHostServer.IntegrationPackageSourceAliasPropertyName));
Assert.False(options.EnvironmentVariableFilter("PATH"));
Assert.Equal(
"13.4.0-pr.17141.gf142085f",
options.EnvironmentVariables?[PrebuiltAppHostServer.IntegrationHostingVersionPropertyName]);
Assert.Equal(
channelSource,
options.EnvironmentVariables?[PrebuiltAppHostServer.IntegrationPackageSourcesPropertyName]);
Assert.Equal(
"private",
options.EnvironmentVariables?[PrebuiltAppHostServer.IntegrationPackageSourceAliasPropertyName]);
Assert.False(options.EnvironmentVariables?.ContainsKey("RestoreAdditionalProjectSources"));
Assert.Equal(
generatedPolicyOverlay?
.Descendants("config")
.Elements("add")
.Single(element => element.Attribute("key")?.Value == "globalPackagesFolder")
.Attribute("value")?
.Value,
options.EnvironmentVariables?[CliPathHelper.NuGetPackagesEnvironmentVariable]);
});
Assert.NotNull(generatedProject);
Assert.Equal(
workspace.WorkspaceRoot.FullName,
nugetConfigDiscoveryDirectory);
var ns = generatedProject!.Root!.GetDefaultNamespace();
var restoreConfigFile = generatedProject.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value;
var restoreSources = generatedProject.Descendants(ns + "RestoreAdditionalProjectSources").FirstOrDefault()?.Value;
Assert.Null(restoreConfigFile);
Assert.True(string.IsNullOrEmpty(restoreSources));
Assert.True(File.Exists(Path.Combine(
workspace.WorkspaceRoot.FullName,
AspireJsonConfiguration.SettingsFolder,
"NuGet.Config")));
var directoryBuildProps = XDocument.Load(Path.Combine(
workingDirectory,
"integration-restore",
"Directory.Build.props"));
Assert.Equal(
Path.Combine(workspace.WorkspaceRoot.FullName, AspireJsonConfiguration.SettingsFolder),
directoryBuildProps.Descendants("RestoreRootConfigDirectory").Single().Value);
Assert.NotNull(generatedPolicyOverlay);
Assert.Empty(generatedPolicyOverlay.Descendants("packageSources"));
Assert.Empty(GetPackagePatternsForKey(generatedPolicyOverlay, "anonymousAlias"));
Assert.Equal(["Aspire*"], GetPackagePatternsForKey(generatedPolicyOverlay, "private"));
var disabledPackageSources = Assert.Single(generatedPolicyOverlay.Descendants("disabledPackageSources"));
Assert.NotNull(disabledPackageSources.Element("clear"));
Assert.Equal(
["anonymousAlias", "unrelated"],
disabledPackageSources
.Elements("add")
.Select(static source => source.Attribute("key")!.Value));
Assert.NotNull(XDocument.Load(ambientConfigPath).Descendants("packageSourceCredentials").ElementAtOrDefault(0));
// Aspire package versions remain in their original (non-pinned) form when no override
// is in play; the exact-version pinning only fires when a single source is selected.
Assert.Collection(
generatedProject.Descendants("PackageReference"),
packageElement =>
{
Assert.Equal("Aspire.Hosting", packageElement.Attribute("Include")?.Value);
Assert.Equal("13.4.0-pr.17141.gf142085f", packageElement.Attribute("Version")?.Value);
},
packageElement =>
{
Assert.Equal("Aspire.Hosting.Redis", packageElement.Attribute("Include")?.Value);
Assert.Equal("13.4.0-pr.17141.gf142085f", packageElement.Attribute("Version")?.Value);
});
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithCredentialBearingChannelSource_OmitsProjectSourceHint()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var credentialMarker = $"credential-marker-{Guid.NewGuid():N}";
var channelSource = $"https://packages.example.invalid/v3/index.json?opaque={credentialMarker}";
ProcessInvocationOptions? buildOptions = null;
await File.WriteAllTextAsync(
Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName),
"""
{
"channel": "daily"
}
""");
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, options, _) =>
{
buildOptions = options;
WriteClosureInputs(
projectFilePath.Directory!,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["MyIntegration.dll"] = "integration-v1"
},
["MyIntegration"]);
return 0;
}
};
var channel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
logger: NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var server = CreatePrebuiltAppHostServer(
workspace,
dotNetCliRunner: dotNetCliRunner,
packagingService: packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17141.gf142085f",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17141.gf142085f"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
]);
Assert.True(result.Success);
Assert.NotNull(buildOptions);
Assert.True(buildOptions.SuppressLogging);
Assert.NotNull(buildOptions.EnvironmentVariableFilter);
Assert.True(buildOptions.EnvironmentVariableFilter(
PrebuiltAppHostServer.IntegrationPackageSourcesPropertyName));
Assert.True(buildOptions.EnvironmentVariableFilter(
PrebuiltAppHostServer.IntegrationPackageSourceAliasPropertyName));
Assert.False(buildOptions.EnvironmentVariables?.ContainsKey(
PrebuiltAppHostServer.IntegrationPackageSourcesPropertyName));
Assert.False(buildOptions.EnvironmentVariables?.ContainsKey(
PrebuiltAppHostServer.IntegrationPackageSourceAliasPropertyName));
Assert.DoesNotContain(
buildOptions.EnvironmentVariables?.Values ?? [],
value => value.Contains(credentialMarker, StringComparison.Ordinal));
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithCredentialBearingAmbientSource_SuppressesRawProcessLogging()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string credentialBearingSource = "https://user:secret@packages.example.com/v3/index.json";
ProcessInvocationOptions? buildOptions = null;
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, options, _) =>
{
buildOptions = options;
WriteDependencyGraphSpec(projectFilePath, (projectFilePath.FullName, credentialBearingSource));
options.StandardErrorCallback?.Invoke(
$"NU1301: Unable to load the service index for source {credentialBearingSource}.");
return 1;
}
};
await File.WriteAllTextAsync(
Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config"),
$$"""
<configuration>
<packageSources>
<add key="private" value="{{credentialBearingSource}}" />
</packageSources>
</configuration>
""");
var layout = CreateBundleLayout(workspace);
var nuGetClient = CreateSettingsAwareNuGetClient();
var server = CreatePrebuiltAppHostServer(
workspace,
layout: layout,
dotNetCliRunner: dotNetCliRunner,
nuGetClient: nuGetClient);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
]);
Assert.False(result.Success);
Assert.NotNull(buildOptions);
Assert.True(buildOptions.SuppressLogging);
var output = string.Join(Environment.NewLine, result.Output!.GetLines().Select(static line => line.Line));
Assert.DoesNotContain("secret", output);
Assert.Contains("packages.example.com/v3/index.json", output);
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithCredentialBearingAmbientSourceAlias_RedactsLogsWithoutOmittingHint()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string credentialMarker = "credential-marker";
const string sourceAlias = $"https://user:{credentialMarker}@packages.example.com";
const string source = "https://packages.example.com/v3/index.json";
ProcessInvocationOptions? buildOptions = null;
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, options, _) =>
{
buildOptions = options;
WriteDependencyGraphSpec(projectFilePath, (projectFilePath.FullName, source));
options.StandardErrorCallback?.Invoke(
$"NU1301: Unable to load the service index for source {sourceAlias}.");
return 1;
}
};
await File.WriteAllTextAsync(
Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config"),
$$"""
<configuration>
<packageSources>
<clear />
<add key="{{sourceAlias}}" value="{{source}}" />
</packageSources>
</configuration>
""");
var layout = CreateBundleLayout(workspace);
var nuGetClient = CreateSettingsAwareNuGetClient();
var channel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", source)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
logger: NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var server = CreatePrebuiltAppHostServer(
workspace,
layout: layout,
dotNetCliRunner: dotNetCliRunner,
nuGetClient: nuGetClient,
packagingService: packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
],
requestedChannel: "daily");
Assert.False(result.Success);
Assert.NotNull(buildOptions);
Assert.True(buildOptions.SuppressLogging);
Assert.Equal(
source,
buildOptions.EnvironmentVariables?[PrebuiltAppHostServer.IntegrationPackageSourcesPropertyName]);
Assert.Equal(
sourceAlias,
buildOptions.EnvironmentVariables?[PrebuiltAppHostServer.IntegrationPackageSourceAliasPropertyName]);
var output = string.Join(Environment.NewLine, result.Output!.GetLines().Select(static line => line.Line));
Assert.DoesNotContain(credentialMarker, output);
Assert.Contains("packages.example.com", output);
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithPackageReferencesAndNoMappings_InvalidatesCacheWhenAmbientNuGetConfigChanges()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var ambientConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
await File.WriteAllTextAsync(ambientConfigPath, """
<configuration>
<config>
<add key="signatureValidationMode" value="accept" />
</config>
</configuration>
""");
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.DiscoverAmbient);
var workingDirectory = GetWorkingDirectory(server);
try
{
var firstResult = await server.PrepareAsync(
"13.4.0",
[IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0")]);
var firstManifestPath = server.IntegrationProbeManifestPath;
await File.WriteAllTextAsync(ambientConfigPath, """
<configuration>
<config>
<add key="signatureValidationMode" value="require" />
</config>
</configuration>
""");
var secondResult = await server.PrepareAsync(
"13.4.0",
[IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0")]);
var secondManifestPath = server.IntegrationProbeManifestPath;
Assert.True(firstResult.Success);
Assert.True(secondResult.Success);
Assert.NotNull(firstManifestPath);
Assert.NotNull(secondManifestPath);
Assert.NotEqual(firstManifestPath, secondManifestPath);
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithPackageReferencesAndExplicitChannel_LoadsAmbientConfigAfterPolicyOverlay()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string channelSource = "https://packages.example.com/v3/index.json";
var ambientConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
await File.WriteAllTextAsync(ambientConfigPath, $$"""
<configuration>
<packageSources>
<add key="private" value="{{channelSource}}" />
</packageSources>
<packageSourceCredentials>
<private>
<add key="Username" value="user" />
<add key="ClearTextPassword" value="secret" />
</private>
</packageSourceCredentials>
<config>
<add key="signatureValidationMode" value="require" />
</config>
</configuration>
""");
var dailyChannel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel])
};
var (server, nuGetClient) = CreatePackageReferenceServer(
workspace,
NuGetSettingsMode.DiscoverAmbient,
packagingService);
XDocument? restoreOverlay = null;
string[]? restoreConfigPaths = null;
string? policyOverlayPath = null;
nuGetClient.RestoreCallback = (_, _, _, _, _, configPaths, _, _, _, _) =>
{
restoreConfigPaths = [.. configPaths];
policyOverlayPath = restoreConfigPaths[0];
restoreOverlay = XDocument.Load(policyOverlayPath);
return Task.CompletedTask;
};
try
{
var result = await server.PrepareAsync(
"13.4.0",
[IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0")],
requestedChannel: "daily");
Assert.True(result.Success);
Assert.NotNull(policyOverlayPath);
Assert.Equal(
[policyOverlayPath, ambientConfigPath],
Assert.IsType<string[]>(restoreConfigPaths)[..2]);
Assert.NotNull(restoreOverlay);
Assert.Empty(restoreOverlay.Descendants("packageSources"));
Assert.Equal(["*", "Aspire*"], GetPackagePatternsForKey(restoreOverlay, "private"));
var ambientConfig = XDocument.Load(ambientConfigPath);
Assert.NotNull(ambientConfig.Descendants("packageSourceCredentials").Single().Element("private"));
Assert.Equal(
"require",
ambientConfig.Descendants("config").Elements("add").Single().Attribute("value")?.Value);
Assert.False(File.Exists(policyOverlayPath));
Assert.NotNull(server.IntegrationProbeManifestPath);
Assert.True(Directory.Exists(Path.GetDirectoryName(server.IntegrationProbeManifestPath)));
}
finally
{
server.Dispose();
DeleteWorkingDirectory(GetWorkingDirectory(server));
}
}
[Fact]
public async Task PrepareAsync_WhenNuGetConfigChanges_InvalidatesRestoreStamp()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json";
var ambientConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
await File.WriteAllTextAsync(ambientConfigPath, """
<configuration>
<packageSources>
<add key="private" value="https://packages.example.com/v1/index.json" />
</packageSources>
</configuration>
""");
var noRestoreValues = new List<bool>();
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MyIntegration.dll"] = "integration-v1"
};
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, noRestore, _, _) =>
{
noRestoreValues.Add(noRestore);
WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]);
return 0;
}
};
var dailyChannel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel])
};
var layout = CreateBundleLayout(workspace);
var nuGetClient = CreateSettingsAwareNuGetClient();
var server = CreatePrebuiltAppHostServer(
workspace,
layout: layout,
dotNetCliRunner: dotNetCliRunner,
packagingService: packagingService,
nuGetClient: nuGetClient);
var workingDirectory = GetWorkingDirectory(server);
var integrations = new[]
{
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
};
try
{
var firstResult = await server.PrepareAsync("13.4.0", integrations, requestedChannel: "daily");
var secondResult = await server.PrepareAsync("13.4.0", integrations, requestedChannel: "daily");
await File.WriteAllTextAsync(ambientConfigPath, """
<configuration>
<packageSources>
<add key="private" value="https://packages.example.com/v2/index.json" />
</packageSources>
</configuration>
""");
var thirdResult = await server.PrepareAsync("13.4.0", integrations, requestedChannel: "daily");
Assert.True(firstResult.Success);
Assert.True(secondResult.Success);
Assert.True(thirdResult.Success);
Assert.Equal([false, false, false], noRestoreValues);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_AmbientOnlyRestoreInvalidatesStalePolicyOverlayStamp()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var ambientConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
await File.WriteAllTextAsync(ambientConfigPath, """
<configuration>
<packageSources>
<add key="private" value="https://packages.example.com/v3/index.json" />
</packageSources>
</configuration>
""");
var noRestoreValues = new List<bool>();
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MyIntegration.dll"] = "integration-v1"
};
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, noRestore, _, _) =>
{
noRestoreValues.Add(noRestore);
WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]);
return 0;
}
};
var dailyChannel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/fake/v3/index.json")],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([dailyChannel])
};
var layout = CreateBundleLayout(workspace);
var nuGetClient = CreateSettingsAwareNuGetClient();
var server = CreatePrebuiltAppHostServer(
workspace,
layout: layout,
dotNetCliRunner: dotNetCliRunner,
packagingService: packagingService,
nuGetClient: nuGetClient);
var workingDirectory = GetWorkingDirectory(server);
var integrations = new[]
{
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
};
try
{
var firstResult = await server.PrepareAsync("13.4.0", integrations, requestedChannel: "daily");
var ambientResult = await server.PrepareAsync("13.4.0", integrations);
var finalResult = await server.PrepareAsync("13.4.0", integrations, requestedChannel: "daily");
Assert.True(firstResult.Success);
Assert.True(ambientResult.Success);
Assert.True(finalResult.Success);
Assert.Equal([false, false, false], noRestoreValues);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithCredentialBearingChannelSource_RedactsProjectRestoreFailure()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string channelSource = "https://feed.blob.core.windows.net/packages/index.json?sig=secret-sig";
const string derivedResource =
"https://feed.blob.core.windows.net/packages/v3-flatcontainer/package/index.json?sig=secret-sig";
var buildCalled = false;
ProcessInvocationOptions? buildOptions = null;
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, options, _) =>
{
buildCalled = true;
buildOptions = options;
WriteDependencyGraphSpec(projectFilePath, (projectFilePath.FullName, channelSource));
options.StandardErrorCallback?.Invoke(
$"NU1301: Unable to load the package resource {derivedResource}.");
return 1;
}
};
var channel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", channelSource)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var server = CreatePrebuiltAppHostServer(
workspace,
dotNetCliRunner: dotNetCliRunner,
packagingService: packagingService);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
],
requestedChannel: "daily");
Assert.False(result.Success);
Assert.True(buildCalled);
Assert.NotNull(buildOptions);
Assert.True(buildOptions.SuppressLogging);
Assert.NotNull(result.Output);
var output = string.Join(Environment.NewLine, result.Output.GetLines().Select(static line => line.Line));
Assert.Contains(
"https://feed.blob.core.windows.net/packages/v3-flatcontainer/package/index.json?***",
output);
Assert.DoesNotContain("secret-sig", output);
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithCredentialBearingReferencedProjectSource_RedactsProjectRestoreFailure()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string referencedProjectSource = "https://packages.example.com/v3/index.json?token=child-secret";
ProcessInvocationOptions? buildOptions = null;
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, options, _) =>
{
buildOptions = options;
WriteDependencyGraphSpec(
projectFilePath,
(projectFilePath.FullName, PackageSources.NuGetOrg),
("/path/to/MyIntegration.csproj", referencedProjectSource));
options.StandardErrorCallback?.Invoke(
$"NU1301: Unable to load the service index for source {referencedProjectSource}.");
return 1;
}
};
var server = CreatePrebuiltAppHostServer(workspace, dotNetCliRunner: dotNetCliRunner);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
]);
Assert.False(result.Success);
Assert.NotNull(buildOptions);
Assert.True(buildOptions.SuppressLogging);
Assert.NotNull(result.Output);
var output = string.Join(Environment.NewLine, result.Output.GetLines().Select(static line => line.Line));
Assert.Contains("https://packages.example.com/v3/index.json", output);
Assert.DoesNotContain("child-secret", output);
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WhenDependencyGraphSpecIsStale_OmitsRawProjectRestoreFailure()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string unverifiedSource = "https://packages.example.com/v3/index.json?token=unverified-secret";
var staleGraphWasDeletedBeforeBuild = false;
string? dependencyGraphSpecPath = null;
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (_, _, options, _) =>
{
staleGraphWasDeletedBeforeBuild = !File.Exists(dependencyGraphSpecPath);
options.StandardErrorCallback?.Invoke(
$"NU1301: Unable to load the service index for source {unverifiedSource}.");
return 1;
}
};
var server = CreatePrebuiltAppHostServer(workspace, dotNetCliRunner: dotNetCliRunner);
var workingDirectory = GetWorkingDirectory(server);
var restoreDirectory = Path.Combine(workingDirectory, IntegrationClosureBuilder.IntegrationRestoreFolderName);
var generatedProjectFile = new FileInfo(
Path.Combine(restoreDirectory, PrebuiltAppHostServer.IntegrationProjectFileName));
var intermediateOutputPath = Path.Combine(restoreDirectory, "obj");
dependencyGraphSpecPath = Path.Combine(
intermediateOutputPath,
$"{generatedProjectFile.Name}.nuget.dgspec.json");
WriteDependencyGraphSpec(
generatedProjectFile,
JsonSerializer.Serialize(new
{
projects = new Dictionary<string, object>
{
[generatedProjectFile.FullName] = new
{
restore = new
{
sources = new Dictionary<string, object>
{
[PackageSources.NuGetOrg] = new { }
}
}
}
}
}),
intermediateOutputPath);
try
{
var result = await server.PrepareAsync(
"13.4.0",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
]);
Assert.False(result.Success);
Assert.True(staleGraphWasDeletedBeforeBuild);
Assert.NotNull(result.Output);
Assert.Equal(
[(OutputLineStream.StdErr, ErrorStrings.IntegrationBuildFailed)],
result.Output.GetLines());
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Theory]
[InlineData(null)]
[InlineData("{")]
public async Task PrepareAsync_WhenDependencyGraphSpecIsUnavailable_OmitsRawProjectRestoreFailure(
string? dependencyGraphSpecContent)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string unverifiedSource = "https://packages.example.com/v3/index.json?token=unverified-secret";
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, options, _) =>
{
if (dependencyGraphSpecContent is not null)
{
WriteDependencyGraphSpec(projectFilePath, dependencyGraphSpecContent);
}
options.StandardErrorCallback?.Invoke(
$"NU1301: Unable to load the service index for source {unverifiedSource}.");
return 1;
}
};
var server = CreatePrebuiltAppHostServer(workspace, dotNetCliRunner: dotNetCliRunner);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
]);
Assert.False(result.Success);
Assert.NotNull(result.Output);
Assert.Equal(
[(OutputLineStream.StdErr, ErrorStrings.IntegrationBuildFailed)],
result.Output.GetLines());
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WhenDependencyGraphSpecIsUnavailable_PreservesPackageDowngradeSummary()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (_, _, options, _) =>
{
options.StandardErrorCallback?.Invoke(
"error NU1605: Warning As Error: Detected package downgrade: Aspire.Hosting from 13.5.0 to 13.4.0.");
return 1;
}
};
var server = CreatePrebuiltAppHostServer(workspace, dotNetCliRunner: dotNetCliRunner);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
]);
Assert.False(result.Success);
Assert.NotNull(result.Output);
Assert.Equal(
[
(
OutputLineStream.StdErr,
string.Format(
CultureInfo.CurrentCulture,
ErrorStrings.IntegrationBuildPackageDowngradeFailed,
VersionHelper.GetDefaultTemplateVersion()))
],
result.Output.GetLines());
}
finally
{
server.Dispose();
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_RestoreFailure_WithAutoDiscoveredLocalSource_FooterShowsEffectiveSource()
{
// When the caller passes no --source but a local hive is auto-discovered, the failure
// footer must identify the effective source that participated in restore.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var localHive = workspace.CreateDirectory("local-aspire-hive").FullName;
var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
await File.WriteAllTextAsync(aspireConfigPath, """
{
"channel": "pr-12345"
}
""");
var prChannel = PackageChannel.CreateExplicitChannel(
name: "pr-12345",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", localHive)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([prChannel])
};
var (server, nuGetClient) = CreatePackageReferenceServer(workspace, NuGetSettingsMode.Isolated, packagingService);
nuGetClient.RestoreCallback = (_, _, _, _, _, _, _, _, _, _) =>
Task.FromException(new InvalidOperationException("simulated restore failure"));
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.12345.gabcdef00",
[IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.12345.gabcdef00")]);
Assert.False(result.Success);
Assert.NotNull(result.Output);
var combined = string.Join('\n', result.Output!.GetLines().Select(static line => line.Line));
Assert.Contains($"--source: {localHive}", combined);
Assert.Contains("channel: pr-12345", combined);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Theory]
[InlineData("https://user:p@ss@host/path", "<unparseable http source>")]
[InlineData("https://user:p#word@host/", "<unparseable http source>")]
[InlineData("http://foo bar/path", "<unparseable http source>")]
[InlineData("HTTPS://user:p@ss@host/path", "<unparseable http source>")]
[InlineData("/tmp/aspire/some path with [brackets]", "/tmp/aspire/some path with [brackets]")]
public void RedactSourceForDisplay_FailsClosedForMalformedHttpButPassesThroughLocalPaths(string input, string expected)
{
// HTTP-looking inputs that Uri.TryCreate cannot parse (e.g. unescaped @ or # in user-info
// or embedded whitespace) must return the sentinel rather than risk exposing credentials.
// Plain non-HTTP inputs pass through because they represent local source paths.
Assert.Equal(expected, PrebuiltAppHostServer.RedactSourceForDisplay(input));
}
[Fact]
public async Task PrepareAsync_WithProjectReferencesAndPackageSourceOverride_UsesPolicyOverlayAndEnvironmentDefault()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packageSourceOverride = Path.Combine(
workspace.WorkspaceRoot.FullName,
"aspire-pr-hive",
"packages");
XDocument? generatedProject = null;
XDocument? restoreOverlay = null;
ProcessInvocationOptions? buildOptions = null;
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MyIntegration.dll"] = "integration-v1"
};
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, options, _) =>
{
generatedProject = XDocument.Load(projectFilePath.FullName);
restoreOverlay = LoadGeneratedPolicyOverlay(projectFilePath);
buildOptions = options;
WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]);
return 0;
}
};
var server = CreatePrebuiltAppHostServer(workspace, dotNetCliRunner: dotNetCliRunner);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0-pr.17166.ga49d604d",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0-pr.17166.ga49d604d"),
IntegrationReference.FromPackage("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
],
packageSourceOverride: packageSourceOverride);
Assert.True(result.Success);
Assert.NotNull(generatedProject);
var ns = generatedProject.Root!.GetDefaultNamespace();
var restoreConfigFile = generatedProject.Descendants(ns + "RestoreConfigFile").FirstOrDefault()?.Value;
Assert.Null(restoreConfigFile);
Assert.True(string.IsNullOrEmpty(
generatedProject.Descendants(ns + "RestoreAdditionalProjectSources").FirstOrDefault()?.Value));
Assert.NotNull(restoreOverlay);
Assert.Single(restoreOverlay.Descendants("packageSources").Elements("add"));
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(restoreOverlay, packageSourceOverride));
Assert.Empty(GetPackagePatternsForSource(restoreOverlay, NuGetOrgSource));
Assert.Equal(
"13.4.0-pr.17166.ga49d604d",
buildOptions?.EnvironmentVariables?[PrebuiltAppHostServer.IntegrationHostingVersionPropertyName]);
Assert.Equal(
packageSourceOverride,
buildOptions?.EnvironmentVariables?[PrebuiltAppHostServer.IntegrationPackageSourcesPropertyName]);
Assert.Equal(
restoreOverlay
.Descendants("packageSources")
.Elements("add")
.Single()
.Attribute("key")?
.Value,
buildOptions?.EnvironmentVariables?[PrebuiltAppHostServer.IntegrationPackageSourceAliasPropertyName]);
Assert.False(buildOptions?.EnvironmentVariables?.ContainsKey("RestoreAdditionalProjectSources"));
Assert.Collection(
generatedProject.Descendants("PackageReference"),
packageElement =>
{
Assert.Equal("Aspire.Hosting", packageElement.Attribute("Include")?.Value);
Assert.Equal("[13.4.0-pr.17166.ga49d604d]", packageElement.Attribute("Version")?.Value);
},
packageElement =>
{
Assert.Equal("Aspire.Hosting.Redis", packageElement.Attribute("Include")?.Value);
Assert.Equal("[13.4.0-pr.17166.ga49d604d]", packageElement.Attribute("Version")?.Value);
},
packageElement =>
{
Assert.Equal("CommunityToolkit.Aspire.Hosting.Redis", packageElement.Attribute("Include")?.Value);
Assert.Equal("1.0.0", packageElement.Attribute("Version")?.Value);
});
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithStagingPinnedProjectOutsideLaunchDirectory_UsesStagingSourcesAndPolicyOverlay()
{
const string stagingFeed = "https://example.com/staging/v3/index.json";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectDirectory = workspace.CreateDirectory("elsewhere");
var config = AspireConfigFile.LoadOrCreate(projectDirectory.FullName);
config.Channel = PackageChannelNames.Staging;
config.Save(projectDirectory.FullName);
var layout = CreateBundleLayout(workspace);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: PackageChannelNames.Stable);
IReadOnlyList<string>? restoreSources = null;
string? restoreWorkingDirectory = null;
string? temporaryNuGetConfigContent = null;
var nuGetClient = CreateIsolatedNuGetClient();
nuGetClient.RestoreCallback = (_, _, _, _, sources, configPaths, workingDirectory, _, _, _) =>
{
restoreSources = sources;
restoreWorkingDirectory = workingDirectory;
temporaryNuGetConfigContent = File.ReadAllText(configPaths[0]);
return Task.CompletedTask;
};
var stagingChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Staging,
PackageChannelQuality.Both,
[
new PackageMapping("Aspire*", stagingFeed),
new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
],
new FakeNuGetPackageCache(),
new TestFeatures(),
NullLogger.Instance,
configureGlobalPackagesFolder: true);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([stagingChannel])
};
var server = CreatePrebuiltAppHostServer(
workspace,
appPath: projectDirectory.FullName,
layout: layout,
packagingService: packagingService,
executionContext: executionContext,
nuGetClient: nuGetClient);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.2.0",
[IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0")]);
Assert.True(result.Success);
Assert.Equal(PackageChannelNames.Staging, result.ChannelName);
Assert.NotNull(restoreSources);
Assert.Empty(restoreSources);
Assert.Equal(projectDirectory.FullName, restoreWorkingDirectory);
Assert.NotNull(temporaryNuGetConfigContent);
Assert.Contains(stagingFeed, temporaryNuGetConfigContent!);
Assert.Contains("Aspire*", temporaryNuGetConfigContent!);
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithOnlyProjectReferences_SetsOnlyProjectLayout()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MyIntegration.dll"] = "integration-v1"
};
var layout = CreateBundleLayout(workspace);
var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], layout: layout);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.2.0",
[IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")]);
Assert.True(
result.Success,
result.Output is null
? null
: string.Join(Environment.NewLine, result.Output.GetLines().Select(static line => line.Line)));
Assert.Null(server.IntegrationProbeManifestPath);
var layoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
Assert.True(File.Exists(Path.Combine(layoutPath, "libs", "MyIntegration.dll")));
var startInfo = server.CreateStartInfo(123);
Assert.Equal(Path.Combine(layoutPath, "libs"), startInfo.Environment[KnownConfigNames.IntegrationLibsPath]);
Assert.False(startInfo.Environment.ContainsKey(KnownConfigNames.IntegrationProbeManifestPath));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithProjectReferences_ReusesProjectLayoutWhenClosureIsUnchanged()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Aspire.Hosting.Redis.dll"] = "redis-v1",
["MyIntegration.dll"] = "integration-v1"
};
var packageMetadata = CreatePackageMetadata();
var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], packageMetadata);
var workingDirectory = GetWorkingDirectory(server);
try
{
var firstResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(firstResult.Success);
var firstLayoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
var secondResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(secondResult.Success);
Assert.Equal(firstLayoutPath, server.SelectedProjectLayoutPath);
Assert.Single(Directory.GetDirectories(Path.Combine(workingDirectory, "project-layouts", "items")));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithMixedReferences_UsesPackageProbeManifestAndCopiesCompleteProjectOutput()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Aspire.Hosting.Redis.dll"] = "redis-v1",
["MyIntegration.dll"] = "integration-v1"
};
var packageMetadata = CreatePackageMetadata();
var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], packageMetadata);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.2.0",
[
IntegrationReference.FromPackage("Aspire.Hosting", "13.2.0"),
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj"),
IntegrationReference.FromNpm("@test/integration", "/path/to/host.mts")
]);
Assert.True(result.Success);
var layoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
var copiedLibs = Directory.GetFiles(Path.Combine(layoutPath, "libs"), "*", SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(Path.Combine(layoutPath, "libs"), path).Replace('\\', '/'))
.OrderBy(static path => path, StringComparer.Ordinal)
.ToList();
Assert.Equal(["MyIntegration.dll"], copiedLibs);
var probeManifestPath = Assert.IsType<string>(server.IntegrationProbeManifestPath);
Assert.Equal(
Path.Combine(workingDirectory, IntegrationPackageProbeManifest.FileName),
probeManifestPath);
using var probeManifest = JsonDocument.Parse(await File.ReadAllTextAsync(probeManifestPath));
var redisAssembly = Assert.Single(
probeManifest.RootElement.GetProperty("managedAssemblies").EnumerateArray(),
assembly => assembly.GetProperty("name").GetString() == "Aspire.Hosting.Redis");
Assert.Equal("Aspire.Hosting.Redis", redisAssembly.GetProperty("packageId").GetString());
Assert.Equal("13.2.0", redisAssembly.GetProperty("packageVersion").GetString());
var generatedProject = XDocument.Load(Path.Combine(workingDirectory, "integration-restore", PrebuiltAppHostServer.IntegrationProjectFileName));
Assert.Collection(
generatedProject.Descendants("PackageReference"),
packageReference =>
{
Assert.Equal("Aspire.Hosting", packageReference.Attribute("Include")?.Value);
Assert.Equal("13.2.0", packageReference.Attribute("Version")?.Value);
},
packageReference =>
{
Assert.Equal("Aspire.Hosting.Redis", packageReference.Attribute("Include")?.Value);
Assert.Equal("13.2.0", packageReference.Attribute("Version")?.Value);
});
Assert.Single(generatedProject.Descendants("ProjectReference"));
await Verify(await File.ReadAllTextAsync(Path.Combine(workingDirectory, "appsettings.json")), "json");
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithMixedReferences_ResolvesOneChannelAndNuGetSettingsSnapshot()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string firstSource = "https://packages.example.com/first/v3/index.json";
const string laterSource = "https://packages.example.com/later/v3/index.json";
var channelLookupCount = 0;
var settingsLookupCount = 0;
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
channelLookupCount++;
var source = channelLookupCount == 1 ? firstSource : laterSource;
var channel = PackageChannel.CreateExplicitChannel(
name: "daily",
quality: PackageChannelQuality.Both,
mappings: [new PackageMapping("Aspire*", source)],
nuGetPackageCache: new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([channel]);
}
};
XDocument? projectRestoreOverlay = null;
var layout = CreateBundleLayout(workspace);
var settingsClient = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var nuGetClient = new FakeNuGetClient
{
GetSettingsCallback = (_, sourceIdentityKey) =>
{
settingsLookupCount++;
var source = settingsLookupCount == 1 ? firstSource : laterSource;
var alias = settingsLookupCount == 1 ? "first-alias" : "later-alias";
return CreateNuGetSettingsInfo(
sources: [(alias, source, true)],
sourceIdentityKey: sourceIdentityKey);
},
WriteNuGetConfigCallback = settingsClient.WriteNuGetConfig
};
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MyIntegration.dll"] = "integration-v1"
};
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, _, _) =>
{
projectRestoreOverlay = LoadGeneratedPolicyOverlay(projectFilePath);
WriteClosureInputs(projectFilePath.Directory!, closureFiles, ["MyIntegration"]);
return 0;
}
};
var server = CreatePrebuiltAppHostServer(
workspace,
layout: layout,
dotNetCliRunner: dotNetCliRunner,
packagingService: packagingService,
nuGetClient: nuGetClient);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync(
"13.4.0",
[
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.4.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
],
requestedChannel: "daily");
Assert.True(result.Success);
Assert.Equal(1, channelLookupCount);
Assert.Equal(1, settingsLookupCount);
Assert.Equal(["*", "Aspire*"], GetPackagePatternsForKey(Assert.IsType<XDocument>(projectRestoreOverlay), "first-alias"));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithProjectReferences_SeparatesPackageAssetsFromProjectLayout()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Aspire.Hosting.Redis.dll"] = "redis-v1",
["fr/Aspire.Hosting.Redis.resources.dll"] = "redis-fr",
["runtimes/test-rid/native/testnative.so"] = "native",
["MyIntegration.dll"] = "integration-v1"
};
var packageMetadata = new Dictionary<string, (string NuGetPackageId, string NuGetPackageVersion, string PathInPackage, string AssetType)>(StringComparer.Ordinal)
{
["Aspire.Hosting.Redis.dll"] = ("Aspire.Hosting.Redis", "13.2.0", "lib/net10.0/Aspire.Hosting.Redis.dll", "runtime"),
["fr/Aspire.Hosting.Redis.resources.dll"] = ("Aspire.Hosting.Redis", "13.2.0", "lib/net10.0/fr/Aspire.Hosting.Redis.resources.dll", "resources"),
["runtimes/test-rid/native/testnative.so"] = ("Aspire.Hosting.Redis", "13.2.0", "runtimes/test-rid/native/testnative.so", "native")
};
var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], packageMetadata);
var workingDirectory = GetWorkingDirectory(server);
try
{
var result = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(result.Success);
var layoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
var copiedLibs = Directory.GetFiles(Path.Combine(layoutPath, "libs"), "*", SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(Path.Combine(layoutPath, "libs"), path).Replace('\\', '/'))
.OrderBy(static path => path, StringComparer.Ordinal)
.ToList();
Assert.Equal(
["MyIntegration.dll"],
copiedLibs);
var probeManifestPath = Assert.IsType<string>(server.IntegrationProbeManifestPath);
using var probeManifest = JsonDocument.Parse(await File.ReadAllTextAsync(probeManifestPath));
Assert.Equal(2, probeManifest.RootElement.GetProperty("managedAssemblies").GetArrayLength());
Assert.Single(probeManifest.RootElement.GetProperty("nativeLibraries").EnumerateArray());
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithProjectReferences_CreatesNewProjectLayoutWhenClosureChanges()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Aspire.Hosting.Redis.dll"] = "redis-v1",
["MyIntegration.dll"] = "integration-v1"
};
var packageMetadata = CreatePackageMetadata();
var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], packageMetadata);
var workingDirectory = GetWorkingDirectory(server);
try
{
var firstResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(firstResult.Success);
var firstLayoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
closureFiles["MyIntegration.dll"] = "integration-v2";
var secondResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(secondResult.Success);
var secondLayoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
Assert.NotEqual(firstLayoutPath, secondLayoutPath);
Assert.True(Directory.Exists(firstLayoutPath));
Assert.True(Directory.Exists(secondLayoutPath));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithProjectReferences_RecreatesProjectLayoutWhenCachedLayoutIsCorrupt()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Aspire.Hosting.Redis.dll"] = "redis-v1",
["MyIntegration.dll"] = "integration-v1"
};
var packageMetadata = CreatePackageMetadata();
var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], packageMetadata);
var workingDirectory = GetWorkingDirectory(server);
try
{
var firstResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(firstResult.Success);
var layoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
var copiedFilePath = Path.Combine(layoutPath, "libs", "MyIntegration.dll");
await File.WriteAllTextAsync(copiedFilePath, "corrupt");
var secondResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(secondResult.Success);
Assert.Equal(layoutPath, server.SelectedProjectLayoutPath);
Assert.Equal("integration-v1", await File.ReadAllTextAsync(copiedFilePath));
Assert.Single(Directory.GetDirectories(Path.Combine(workingDirectory, "project-layouts", "items")));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public async Task PrepareAsync_WithProjectReferences_DoesNotTouchLockedPreviousProjectLayout()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Aspire.Hosting.Redis.dll"] = "redis-v1",
["MyIntegration.dll"] = "integration-v1"
};
var packageMetadata = CreatePackageMetadata();
var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], packageMetadata);
var workingDirectory = GetWorkingDirectory(server);
try
{
var firstResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(firstResult.Success);
var firstLayoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
var lockedFilePath = Path.Combine(firstLayoutPath, "libs", "MyIntegration.dll");
using (var lockedFile = new FileStream(lockedFilePath, FileMode.Open, FileAccess.Read, FileShare.None))
{
closureFiles["MyIntegration.dll"] = "integration-v2";
var secondResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(secondResult.Success);
var secondLayoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
Assert.NotEqual(firstLayoutPath, secondLayoutPath);
Assert.True(File.Exists(lockedFilePath));
Assert.True(File.Exists(Path.Combine(secondLayoutPath, "libs", "MyIntegration.dll")));
}
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
[Fact]
public void ClosureManifest_WithPackageBackedEntries_ChangesFingerprintWhenPackageSourcePathChanges()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var firstPackageRoot = workspace.WorkspaceRoot.CreateSubdirectory("packages-a");
var secondPackageRoot = workspace.WorkspaceRoot.CreateSubdirectory("packages-b");
var firstSourcePath = Path.Combine(firstPackageRoot.FullName, "Aspire.Hosting.Redis.dll");
var secondSourcePath = Path.Combine(secondPackageRoot.FullName, "Aspire.Hosting.Redis.dll");
File.WriteAllText(firstSourcePath, "redis");
File.WriteAllText(secondSourcePath, "redis");
var firstManifest = AppHostServerClosureManifest.Create(
[
new AppHostServerClosureSource(
firstSourcePath,
"Aspire.Hosting.Redis.dll",
"Aspire.Hosting.Redis",
"13.2.0",
"lib/net10.0/Aspire.Hosting.Redis.dll",
"sha512-redis",
"runtime")
],
"{}",
CancellationToken.None);
var secondManifest = AppHostServerClosureManifest.Create(
[
new AppHostServerClosureSource(
secondSourcePath,
"Aspire.Hosting.Redis.dll",
"Aspire.Hosting.Redis",
"13.2.0",
"lib/net10.0/Aspire.Hosting.Redis.dll",
"sha512-redis",
"runtime")
],
"{}",
CancellationToken.None);
Assert.NotEqual(firstManifest.ManifestFingerprint, secondManifest.ManifestFingerprint);
}
[Fact]
public void ClosureManifest_ProjectLayoutIdentityExcludesPackageBackedEntries()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var firstPackageRoot = workspace.WorkspaceRoot.CreateSubdirectory("packages-a");
var secondPackageRoot = workspace.WorkspaceRoot.CreateSubdirectory("packages-b");
var projectRoot = workspace.WorkspaceRoot.CreateSubdirectory("project");
var firstPackagePath = Path.Combine(firstPackageRoot.FullName, "Aspire.Hosting.Redis.dll");
var secondPackagePath = Path.Combine(secondPackageRoot.FullName, "Aspire.Hosting.Redis.dll");
var projectPath = Path.Combine(projectRoot.FullName, "MyIntegration.dll");
File.WriteAllText(firstPackagePath, "redis");
File.WriteAllText(secondPackagePath, "redis");
File.WriteAllText(projectPath, "integration");
var firstManifest = AppHostServerClosureManifest.Create(
[
new AppHostServerClosureSource(
firstPackagePath,
"Aspire.Hosting.Redis.dll",
"Aspire.Hosting.Redis",
"13.2.0",
"lib/net10.0/Aspire.Hosting.Redis.dll",
"sha512-redis",
"runtime"),
new AppHostServerClosureSource(projectPath, "MyIntegration.dll")
],
"{}",
CancellationToken.None);
var secondManifest = AppHostServerClosureManifest.Create(
[
new AppHostServerClosureSource(
secondPackagePath,
"Aspire.Hosting.Redis.dll",
"Aspire.Hosting.Redis",
"13.2.0",
"lib/net10.0/Aspire.Hosting.Redis.dll",
"sha512-redis",
"runtime"),
new AppHostServerClosureSource(projectPath, "MyIntegration.dll")
],
"{}",
CancellationToken.None);
Assert.NotEqual(firstManifest.ManifestFingerprint, secondManifest.ManifestFingerprint);
Assert.Equal(firstManifest.ProjectLayoutFingerprint, secondManifest.ProjectLayoutFingerprint);
Assert.Equal(firstManifest.GetProjectLayoutManifestLines(), secondManifest.GetProjectLayoutManifestLines());
}
[Fact]
[PlatformSpecific(TestPlatforms.AnyUnix)]
public async Task ReadClosureManifestAsync_PreservesTrailingWhitespaceInPaths()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var restoreDirectory = workspace.CreateDirectory("restore");
var sourcePath = Path.Combine(restoreDirectory.FullName, "MyIntegration.dll ");
var relativePath = "MyIntegration.dll ";
File.WriteAllText(sourcePath, "integration");
File.WriteAllLines(
Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureSourcesFileName),
[sourcePath]);
File.WriteAllLines(
Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureMetadataFileName),
["|||"]);
File.WriteAllLines(
Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureTargetsFileName),
[relativePath]);
var intermediateOutputPath = Path.Combine(restoreDirectory.FullName, "obj");
WriteProjectAssetsFile(intermediateOutputPath, packageMetadata: null);
var manifest = await IntegrationClosureBuilder.ReadClosureManifestAsync(
restoreDirectory.FullName,
Path.Combine(intermediateOutputPath, IntegrationClosureBuilder.ProjectAssetsFileName),
"{}",
ClosureFileMissingBehavior.Throw,
logger: null,
CancellationToken.None);
var entry = Assert.Single(Assert.IsType<AppHostServerClosureManifest>(manifest).Entries);
Assert.Equal(sourcePath, entry.SourcePath);
Assert.Equal(relativePath, entry.RelativePath);
}
[Fact]
public async Task PrepareAsync_WithProjectReferences_ReusesProjectLayoutWhenOnlyPackageTimestampChanges()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Aspire.Hosting.Redis.dll"] = "redis-v1",
["MyIntegration.dll"] = "integration-v1"
};
var packageMetadata = CreatePackageMetadata();
var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], packageMetadata);
var workingDirectory = GetWorkingDirectory(server);
try
{
var firstResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(firstResult.Success);
var firstLayoutPath = Assert.IsType<string>(server.SelectedProjectLayoutPath);
var packageSourcePath = Path.Combine(workingDirectory, "integration-restore", "closure-sources", "Aspire.Hosting.Redis.dll");
File.SetLastWriteTimeUtc(packageSourcePath, File.GetLastWriteTimeUtc(packageSourcePath).AddMinutes(5));
var secondResult = await server.PrepareAsync("13.2.0", CreateProjectReferenceIntegrations());
Assert.True(secondResult.Success);
Assert.Equal(firstLayoutPath, server.SelectedProjectLayoutPath);
Assert.Single(Directory.GetDirectories(Path.Combine(workingDirectory, "project-layouts", "items")));
}
finally
{
DeleteWorkingDirectory(workingDirectory);
}
}
private static IReadOnlyList<IntegrationReference> CreateProjectReferenceIntegrations()
{
return
[
IntegrationReference.FromPackage("Aspire.Hosting", "13.2.0"),
IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.2.0"),
IntegrationReference.FromProject("MyIntegration", "/path/to/MyIntegration.csproj")
];
}
private static PrebuiltAppHostServer CreateProjectReferenceServer(
TemporaryWorkspace workspace,
IReadOnlyDictionary<string, string> closureFiles,
IReadOnlyList<string> projectReferenceAssemblyNames,
IReadOnlyDictionary<string, (string NuGetPackageId, string NuGetPackageVersion, string PathInPackage, string AssetType)>? packageMetadata = null,
LayoutConfiguration? layout = null)
{
var dotNetCliRunner = new TestDotNetCliRunner
{
BuildAsyncCallback = (projectFilePath, _, _, _) =>
{
WriteClosureInputs(projectFilePath.Directory!, closureFiles, projectReferenceAssemblyNames, packageMetadata);
return 0;
}
};
return CreatePrebuiltAppHostServer(workspace, layout: layout, dotNetCliRunner: dotNetCliRunner);
}
private static (PrebuiltAppHostServer Server, FakeNuGetClient NuGetClient) CreatePackageReferenceServer(
TemporaryWorkspace workspace,
NuGetSettingsMode settingsMode,
IPackagingService? packagingService = null,
CliExecutionContext? executionContext = null)
{
var layout = CreateBundleLayout(workspace);
var nuGetClient = settingsMode switch
{
NuGetSettingsMode.Isolated => CreateIsolatedNuGetClient(),
NuGetSettingsMode.DiscoverAmbient => CreateSettingsAwareNuGetClient(),
_ => throw new ArgumentOutOfRangeException(nameof(settingsMode))
};
var server = CreatePrebuiltAppHostServer(
workspace,
layout: layout,
packagingService: packagingService ?? MockPackagingServiceFactory.Create(),
nuGetClient: nuGetClient,
executionContext: executionContext);
return (server, nuGetClient);
}
private static LayoutConfiguration CreateBundleLayout(TemporaryWorkspace workspace)
{
var layoutRoot = workspace.CreateDirectory("layout");
var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName);
layoutRoot.CreateSubdirectory(BundleDiscovery.DashboardDirectoryName);
var terminalDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.TerminalHostDirectoryName);
File.WriteAllText(Path.Combine(terminalDirectory.FullName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.TerminalHostExecutableName)), "native");
File.WriteAllText(
Path.Combine(
managedDirectory.FullName,
BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
string.Empty);
return new LayoutConfiguration { LayoutPath = layoutRoot.FullName };
}
private static FakeNuGetClient CreateIsolatedNuGetClient()
{
var settingsClient = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
return new FakeNuGetClient
{
WriteNuGetConfigCallback = settingsClient.WriteNuGetConfig
};
}
private static FakeNuGetClient CreateSettingsAwareNuGetClient()
{
var settingsClient = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
return new FakeNuGetClient
{
GetSettingsCallback = settingsClient.GetSettings,
WriteNuGetConfigCallback = settingsClient.WriteNuGetConfig
};
}
private static string[] GetConfiguredPackageSources(FakeNuGetClient nuGetClient)
=> nuGetClient.LastConfiguredRestoreSources?.ToArray() ?? [];
private static void WriteClosureInputs(
DirectoryInfo restoreDirectory,
IReadOnlyDictionary<string, string> closureFiles,
IReadOnlyList<string> projectReferenceAssemblyNames,
IReadOnlyDictionary<string, (string NuGetPackageId, string NuGetPackageVersion, string PathInPackage, string AssetType)>? packageMetadata = null)
{
var sourceRoot = restoreDirectory.CreateSubdirectory("closure-sources");
var metadataLines = new List<string>();
var sourcePaths = new List<string>();
var targetPaths = new List<string>();
foreach (var (relativePath, content) in closureFiles.OrderBy(static file => file.Key, StringComparer.Ordinal))
{
var sourcePath = Path.Combine(sourceRoot.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar));
var sourcePathDirectory = Path.GetDirectoryName(sourcePath);
if (!string.IsNullOrEmpty(sourcePathDirectory))
{
Directory.CreateDirectory(sourcePathDirectory);
}
if (!File.Exists(sourcePath) || File.ReadAllText(sourcePath) != content)
{
File.WriteAllText(sourcePath, content);
}
sourcePaths.Add(sourcePath);
targetPaths.Add(relativePath.Replace('/', Path.DirectorySeparatorChar));
metadataLines.Add(packageMetadata is not null && packageMetadata.TryGetValue(relativePath, out var package)
? $"{package.NuGetPackageId}|{package.NuGetPackageVersion}|{package.PathInPackage}|{package.AssetType}"
: "|||");
}
WriteProjectAssetsFile(GetIntermediateOutputPath(restoreDirectory), packageMetadata);
File.WriteAllLines(Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureMetadataFileName), metadataLines);
File.WriteAllLines(Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureSourcesFileName), sourcePaths);
File.WriteAllLines(Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ClosureTargetsFileName), targetPaths);
File.WriteAllLines(Path.Combine(restoreDirectory.FullName, IntegrationClosureBuilder.ProjectRefAssemblyNamesFileName), projectReferenceAssemblyNames);
}
private static IReadOnlyDictionary<string, (string NuGetPackageId, string NuGetPackageVersion, string PathInPackage, string AssetType)> CreatePackageMetadata()
{
return new Dictionary<string, (string NuGetPackageId, string NuGetPackageVersion, string PathInPackage, string AssetType)>(StringComparer.Ordinal)
{
["Aspire.Hosting.Redis.dll"] = ("Aspire.Hosting.Redis", "13.2.0", "lib/net10.0/Aspire.Hosting.Redis.dll", "runtime")
};
}
private static void CreateDependencyPackage(
DirectoryInfo feedDirectory,
string packageId,
string packageVersion,
string? dependencyId = null,
string? dependencyVersion = null,
IReadOnlyDictionary<string, string>? packageFiles = null)
{
var packagePath = Path.Combine(feedDirectory.FullName, $"{packageId}.{packageVersion}.nupkg");
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec");
using (var writer = new StreamWriter(nuspecEntry.Open()))
{
var dependencies = dependencyId is null
? string.Empty
: $"""
<dependencies>
<group targetFramework="net10.0">
<dependency id="{dependencyId}" version="{dependencyVersion}" />
</group>
</dependencies>
""";
writer.Write($$"""
<?xml version="1.0" encoding="utf-8"?>
<package>
<metadata>
<id>{{packageId}}</id>
<version>{{packageVersion}}</version>
<authors>Aspire tests</authors>
<description>Dependency graph test package.</description>
{{dependencies}}
</metadata>
</package>
""");
}
if (packageFiles is not null)
{
foreach (var (path, content) in packageFiles)
{
using var writer = new StreamWriter(archive.CreateEntry(path).Open());
writer.Write(content);
}
}
}
private static string GetIntermediateOutputPath(DirectoryInfo restoreDirectory)
{
var document = XDocument.Load(Path.Combine(restoreDirectory.FullName, "Directory.Build.props"));
return document.Descendants("BaseIntermediateOutputPath").Single().Value;
}
private static void WriteDependencyGraphSpec(
FileInfo generatedProjectFile,
params (string ProjectPath, string Source)[] projectSources)
{
var projects = projectSources.ToDictionary(
static entry => entry.ProjectPath,
static entry => new
{
restore = new
{
sources = new Dictionary<string, object>
{
[entry.Source] = new { }
}
}
},
StringComparer.Ordinal);
var intermediateOutputPath = GetIntermediateOutputPath(generatedProjectFile.Directory!);
WriteDependencyGraphSpec(generatedProjectFile, JsonSerializer.Serialize(new { projects }), intermediateOutputPath);
}
private static void WriteDependencyGraphSpec(FileInfo generatedProjectFile, string content)
{
var intermediateOutputPath = GetIntermediateOutputPath(generatedProjectFile.Directory!);
WriteDependencyGraphSpec(generatedProjectFile, content, intermediateOutputPath);
}
private static void WriteDependencyGraphSpec(
FileInfo generatedProjectFile,
string content,
string intermediateOutputPath)
{
Directory.CreateDirectory(intermediateOutputPath);
File.WriteAllText(
Path.Combine(intermediateOutputPath, $"{generatedProjectFile.Name}.nuget.dgspec.json"),
content);
}
private static void WriteProjectAssetsFile(
string intermediateOutputPath,
IReadOnlyDictionary<string, (string NuGetPackageId, string NuGetPackageVersion, string PathInPackage, string AssetType)>? packageMetadata)
{
var objDirectory = Directory.CreateDirectory(intermediateOutputPath);
var libraries = packageMetadata is null
? string.Empty
: string.Join(
",\n",
packageMetadata.Values
.GroupBy(static package => (package.NuGetPackageId, package.NuGetPackageVersion))
.Select(static group => group.First())
.OrderBy(static package => package.NuGetPackageId, StringComparer.Ordinal)
.ThenBy(static package => package.NuGetPackageVersion, StringComparer.Ordinal)
.Select(static package => $$"""
"{{package.NuGetPackageId}}/{{package.NuGetPackageVersion}}": {
"sha512": "sha512-{{package.NuGetPackageId}}-{{package.NuGetPackageVersion}}",
"type": "package",
"path": "{{package.NuGetPackageId.ToLowerInvariant()}}/{{package.NuGetPackageVersion}}",
"files": [
"{{package.PathInPackage}}"
]
}
"""));
var projectAssetsContent = $$"""
{
"libraries": {
{{libraries}}
}
}
""";
File.WriteAllText(Path.Combine(objDirectory.FullName, "project.assets.json"), projectAssetsContent);
}
private static string GetWorkingDirectory(PrebuiltAppHostServer server)
{
return Assert.IsType<string>(
typeof(PrebuiltAppHostServer)
.GetField("_workingDirectory", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(server));
}
[Theory]
[InlineData("13.6.0", "13.5.0", false)]
[InlineData("13.6.0", "13.2.0", false)]
[InlineData("13.5.0", "13.6.0", true)]
[InlineData("13.5.0", "13.6.0-dev", true)]
[InlineData("13.5.0", "14.0.0", true)]
public async Task CreateStartInfo_WithNativeDashboard_UsesResolvedHostingVersion(string sdkVersion, string hostingVersion, bool useNativeDashboard)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layout = CreateBundleLayout(workspace);
var dashboardPath = Assert.IsType<string>(layout.GetDashboardPath());
File.WriteAllText(dashboardPath, string.Empty);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Aspire.Hosting.dll"] = "hosting",
["MyIntegration.dll"] = "integration"
};
var packageMetadata = new Dictionary<string, (string NuGetPackageId, string NuGetPackageVersion, string PathInPackage, string AssetType)>(StringComparer.Ordinal)
{
["Aspire.Hosting.dll"] = ("Aspire.Hosting", hostingVersion, "lib/net10.0/Aspire.Hosting.dll", "runtime")
};
using var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], packageMetadata, layout);
var result = await server.PrepareAsync(sdkVersion, CreateProjectReferenceIntegrations());
Assert.True(result.Success, result.Output?.ToString());
var startInfo = server.CreateStartInfo(123);
Assert.Equal(useNativeDashboard ? dashboardPath : layout.GetManagedPath(), startInfo.Environment[BundleDiscovery.DashboardPathEnvVar]);
Assert.Equal(layout.GetManagedPath(), startInfo.Environment[BundleDiscovery.TerminalHostPathEnvVar]);
Assert.Equal("terminalhost", startInfo.Environment[BundleDiscovery.TerminalHostInvocationArgsEnvVar]);
}
[Fact]
public async Task CreateStartInfo_WithNativeDashboard_InvalidHostingAssembly_UsesManagedDispatcher()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layout = CreateBundleLayout(workspace);
File.WriteAllText(Assert.IsType<string>(layout.GetDashboardPath()), string.Empty);
var closureFiles = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Aspire.Hosting.dll"] = "invalid assembly",
["MyIntegration.dll"] = "integration"
};
using var server = CreateProjectReferenceServer(workspace, closureFiles, ["MyIntegration"], null, layout);
var result = await server.PrepareAsync("13.6.0", CreateProjectReferenceIntegrations());
Assert.True(result.Success, result.Output?.ToString());
var startInfo = server.CreateStartInfo(123);
Assert.Equal(layout.GetManagedPath(), startInfo.Environment[BundleDiscovery.DashboardPathEnvVar]);
}
[Theory]
[InlineData("13.5.0", false)]
[InlineData("13.6.0-dev", true)]
[InlineData("", false)]
[InlineData("invalid", false)]
public async Task CreateStartInfo_WithNativeDashboard_UsesSdkVersionWhenHostingVersionUnavailable(string sdkVersion, bool useNativeDashboard)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layout = CreateBundleLayout(workspace);
var dashboardPath = Assert.IsType<string>(layout.GetDashboardPath());
File.WriteAllText(dashboardPath, string.Empty);
using var server = CreatePrebuiltAppHostServer(workspace, layout: layout);
var result = await server.PrepareAsync(sdkVersion, []);
Assert.True(result.Success, result.Output?.ToString());
var startInfo = server.CreateStartInfo(123);
Assert.Equal(useNativeDashboard ? dashboardPath : layout.GetManagedPath(), startInfo.Environment[BundleDiscovery.DashboardPathEnvVar]);
}
[Theory]
[InlineData("dashboard")]
[InlineData("bundle/dashboard")]
[InlineData("custom dashboard")]
public void CreateStartInfo_WithNativeDashboard_PrefersNativeExecutable(string dashboardComponentPath)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layout = CreateBundleLayout(workspace);
layout.Components.Dashboard = dashboardComponentPath;
var dashboardDirectory = Assert.IsType<string>(layout.GetComponentPath(LayoutComponent.Dashboard));
Directory.CreateDirectory(dashboardDirectory);
var dashboardPath = Path.Combine(
dashboardDirectory,
BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName));
File.WriteAllText(dashboardPath, string.Empty);
var server = CreatePrebuiltAppHostServer(workspace, layout: layout);
var startInfo = server.CreateStartInfo(123);
Assert.Equal(dashboardPath, startInfo.Environment[BundleDiscovery.DashboardPathEnvVar]);
Assert.Equal(layout.GetTerminalHostPath(), startInfo.Environment[BundleDiscovery.TerminalHostPathEnvVar]);
Assert.Equal(string.Empty, startInfo.Environment[BundleDiscovery.TerminalHostInvocationArgsEnvVar]);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CreateStartInfo_WithoutNativeDashboard_UsesManagedDispatcher(bool dashboardComponentConfigured)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layout = CreateBundleLayout(workspace);
if (!dashboardComponentConfigured)
{
layout.Components.Dashboard = null;
}
var server = CreatePrebuiltAppHostServer(workspace, layout: layout);
var startInfo = server.CreateStartInfo(123);
Assert.Equal(layout.GetManagedPath(), startInfo.Environment[BundleDiscovery.DashboardPathEnvVar]);
}
[Fact]
public void CreateStartInfo_WithNativeDashboard_PreservesCompatibilityOverride()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layout = CreateBundleLayout(workspace);
File.WriteAllText(Assert.IsType<string>(layout.GetDashboardPath()), string.Empty);
var server = CreatePrebuiltAppHostServer(workspace, layout: layout);
var environmentVariables = new Dictionary<string, string>
{
[BundleDiscovery.DashboardPathEnvVar] = Assert.IsType<string>(layout.GetManagedPath())
};
var startInfo = server.CreateStartInfo(123, environmentVariables);
Assert.Equal(layout.GetManagedPath(), startInfo.Environment[BundleDiscovery.DashboardPathEnvVar]);
}
[Fact]
public void CreateStartInfo_WithTerminalHostOverrides_PreservesOverrides()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layout = CreateBundleLayout(workspace);
var server = CreatePrebuiltAppHostServer(workspace, layout: layout);
var environmentVariables = new Dictionary<string, string>
{
[BundleDiscovery.TerminalHostPathEnvVar] = "custom-terminal-host",
[BundleDiscovery.TerminalHostInvocationArgsEnvVar] = "custom-args"
};
var startInfo = server.CreateStartInfo(123, environmentVariables);
Assert.Equal("custom-terminal-host", startInfo.Environment[BundleDiscovery.TerminalHostPathEnvVar]);
Assert.Equal("custom-args", startInfo.Environment[BundleDiscovery.TerminalHostInvocationArgsEnvVar]);
}
private static XDocument LoadGeneratedPolicyOverlay(FileInfo projectFilePath)
{
var props = XDocument.Load(Path.Combine(projectFilePath.DirectoryName!, "Directory.Build.props"));
var policyDirectory = props
.Descendants("RestoreRootConfigDirectory")
.Single()
.Value;
return XDocument.Load(Path.Combine(policyDirectory, "NuGet.Config"));
}
private static NuGetSettingsInfo CreateNuGetSettingsInfo(
IEnumerable<string>? configPaths = null,
IEnumerable<(string Name, string Source, bool IsEnabled)>? sources = null,
IEnumerable<(string SourceKey, string[] Patterns)>? packageSourceMappings = null,
IEnumerable<string>? disabledPackageSourceKeys = null,
IEnumerable<string>? reservedPackageSourceKeys = null,
IEnumerable<string>? credentialSourceKeys = null,
IEnumerable<string>? clientCertificateSourceKeys = null,
string cacheIdentity = "settings",
byte[]? sourceIdentityKey = null)
{
sourceIdentityKey ??= s_sourceIdentityKey;
var sourceArray = sources?.ToArray() ?? [];
var mappingArray = packageSourceMappings?.ToArray() ?? [];
var disabledSourceKeyArray = disabledPackageSourceKeys?.ToArray() ?? [];
var credentialSourceKeySet = credentialSourceKeys?.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? [];
var clientCertificateSourceKeySet = clientCertificateSourceKeys?.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? [];
var reservedSourceKeyArray = reservedPackageSourceKeys?.ToArray()
?? sourceArray
.Select(static source => source.Name)
.Concat(mappingArray.Select(static mapping => mapping.SourceKey))
.Concat(disabledSourceKeyArray)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return new NuGetSettingsInfo(
configPaths?.ToArray() ?? [],
cacheIdentity,
sourceArray
.Select(source => new NuGetSourceInfo(
source.Name,
NuGetSourceIdentity.Compute(source.Source, sourceIdentityKey),
source.IsEnabled,
credentialSourceKeySet.Contains(source.Name),
clientCertificateSourceKeySet.Contains(source.Name)))
.ToArray(),
sourceArray
.Select(static source => source.Source)
.Where(NuGetSourceIdentity.HasCredentialMaterial)
.Distinct(StringComparer.Ordinal)
.ToArray(),
mappingArray
.Select(static mapping => new NuGetPackageSourceMapping(mapping.SourceKey, mapping.Patterns))
.ToArray(),
disabledSourceKeyArray,
reservedSourceKeyArray,
sourceIdentityKey);
}
[Fact]
public void CreateStartInfo_SetsCliLogFilePathEnvironmentVariable()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var layout = CreateBundleLayout(workspace);
var executionContext = TestExecutionContextFactory.CreateTestContext();
var nuGetClient = CreateIsolatedNuGetClient();
var server = CreatePrebuiltAppHostServer(
workspace,
layout: layout,
executionContext: executionContext,
nuGetClient: nuGetClient);
var startInfo = server.CreateStartInfo(123);
Assert.Equal(executionContext.LogFilePath, startInfo.Environment[KnownConfigNames.CliLogFilePath]);
}
private static string[] GetPackagePatternsForSource(XDocument doc, string source)
{
var sourceKey = doc.Descendants("packageSources")
.Elements("add")
.SingleOrDefault(element => PackageSourceIdentity.Comparer.Equals(
element.Attribute("value")?.Value,
source))
?.Attribute("key")
?.Value;
sourceKey ??= doc.Descendants("packageSource")
.Select(static element => element.Attribute("key")?.Value)
.FirstOrDefault(key => key is not null && PackageSourceIdentity.Comparer.Equals(key, source));
if (sourceKey is null)
{
return [];
}
return GetPackagePatternsForKey(doc, sourceKey);
}
private static string[] GetPatterns(
IReadOnlyList<NuGetPackageSourceMapping> mappings,
string sourceKey)
=> [.. mappings
.SingleOrDefault(mapping => string.Equals(
mapping.SourceKey,
sourceKey,
StringComparison.OrdinalIgnoreCase))
?.Patterns ?? []];
private static string[] GetPackagePatternsForKey(XDocument doc, string sourceKey)
{
return [.. doc.Descendants("packageSource")
.Where(element => string.Equals(
element.Attribute("key")?.Value,
sourceKey,
StringComparison.OrdinalIgnoreCase))
.Elements("package")
.Select(static element => element.Attribute("pattern")?.Value)
.OfType<string>()];
}
private static void DeleteWorkingDirectory(string workingDirectory)
{
if (Directory.Exists(workingDirectory))
{
Directory.Delete(workingDirectory, recursive: true);
}
}
private enum NuGetSettingsMode
{
Isolated,
DiscoverAmbient
}
private sealed class FixedLayoutDiscovery(LayoutConfiguration layout) : ILayoutDiscovery
{
public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) => layout;
public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null) => layout.GetComponentPath(component);
public bool IsBundleModeAvailable(string? projectDirectory = null) => true;
}
}