// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Templating;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging.Abstractions;
using System.Globalization;
using System.Xml.Linq;
namespace Aspire.Cli.Tests.Templating;
/// <summary>
/// Channel-resolution behavior for <see cref="TemplateNuGetConfigService"/>.
/// Unqualified local CLI template resolution uses directory-backed channels, while other
/// unqualified template resolution retains the implicit-channel and opted-in hive rules.
/// Explicit channel input for every entry point comes from its caller-supplied argument.
/// </summary>
public class TemplateNuGetConfigServiceTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_CreatesSelfContainedConfigWithoutAmbientSources()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var outputDirectory = workspace.WorkspaceRoot.CreateSubdirectory("output");
await File.WriteAllTextAsync(
Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"),
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="ambient-private" value="https://private.example/v3/index.json" />
</packageSources>
<disabledPackageSources>
<add key="ambient-private" value="true" />
</disabledPackageSources>
<packageSourceCredentials>
<ambient-private>
<add key="Username" value="user" />
<add key="ClearTextPassword" value="secret" />
</ambient-private>
</packageSourceCredentials>
<packageSourceMapping>
<packageSource key="ambient-private">
<package pattern="Contoso.*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
var service = CreateService();
const string sourceOverride = "/tmp/aspire-pr-hive/packages";
Assert.True(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: null, outputDirectory.FullName, CancellationToken.None));
var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config"));
Assert.Contains(doc.Root!.Element("packageSources")!.Elements("clear"), _ => true);
Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == sourceOverride);
Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == PackageSources.NuGetOrg);
Assert.DoesNotContain(doc.Descendants("add"), e => (string?)e.Attribute("value") == "https://private.example/v3/index.json");
Assert.Null(doc.Root!.Element("disabledPackageSources"));
Assert.Null(doc.Root!.Element("packageSourceCredentials"));
Assert.Empty(GetPackagePatternsForSource(doc, "ambient-private"));
}
[Fact]
public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_PreservesRequestedChannelFallbackMappings()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var outputDirectory = workspace.WorkspaceRoot.CreateSubdirectory("output");
const string sourceOverride = "/tmp/aspire-pr-hive/packages";
const string channelAspireSource = "https://example.invalid/aspire";
const string communitySource = "https://example.invalid/community";
const string fallbackSource = "https://example.invalid/fallback";
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var channel = PackageChannel.CreateExplicitChannel(
"daily",
PackageChannelQuality.Both,
[
new PackageMapping("Aspire*", channelAspireSource),
new PackageMapping("CommunityToolkit*", communitySource),
new PackageMapping(PackageMapping.AllPackages, fallbackSource),
],
new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([channel]);
}
};
var service = CreateService(packagingService: packagingService);
Assert.True(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: "daily", outputDirectory.FullName, CancellationToken.None));
var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config"));
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride));
Assert.Equal(["CommunityToolkit*"], GetPackagePatternsForSource(doc, communitySource));
Assert.Equal([PackageMapping.AllPackages], GetPackagePatternsForSource(doc, fallbackSource));
Assert.Empty(GetPackagePatternsForSource(doc, channelAspireSource));
Assert.Empty(GetPackagePatternsForSource(doc, PackageSources.NuGetOrg));
}
[Fact]
public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_UpdatesOnlyProjectLocalConfig()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var outputDirectory = workspace.WorkspaceRoot.CreateSubdirectory("output");
await File.WriteAllTextAsync(
Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"),
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="parent-private" value="https://parent.example/v3/index.json" />
</packageSources>
</configuration>
""");
await File.WriteAllTextAsync(
Path.Combine(outputDirectory.FullName, "nuget.config"),
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="project-local" value="https://project.example/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="project-local">
<package pattern="Project.*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
var service = CreateService();
const string sourceOverride = "/tmp/aspire-pr-hive/packages";
Assert.True(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: null, outputDirectory.FullName, CancellationToken.None));
var doc = XDocument.Load(Path.Combine(outputDirectory.FullName, "nuget.config"));
Assert.Contains(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == "https://project.example/v3/index.json");
Assert.DoesNotContain(doc.Root!.Element("packageSources")!.Elements("add"), e => (string?)e.Attribute("value") == "https://parent.example/v3/index.json");
Assert.Equal(["Aspire*"], GetPackagePatternsForSource(doc, sourceOverride));
Assert.Equal(["Project.*"], GetPackagePatternsForSource(doc, "project-local"));
}
[Fact]
public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_NullSourceShortCircuits()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => throw new InvalidOperationException("Channel lookup should not run without a source override.")
};
var service = CreateService(packagingService: packagingService);
Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: null, channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None));
Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: "", channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None));
Assert.False(await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride: " ", channelName: "daily", workspace.WorkspaceRoot.FullName, CancellationToken.None));
}
[Theory]
[InlineData("https://user:token@example.invalid/v3/index.json")]
[InlineData("https://example.invalid/v3/index.json?sig=token")]
[InlineData("https://example.invalid/v3/index.json#token")]
public async Task CreateOrUpdateNuGetConfigForSourceOverrideAsync_CredentialBearingHttpSourceThrows(string sourceOverride)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var service = CreateService();
await Assert.ThrowsAsync<ArgumentException>(
async () => await service.CreateOrUpdateNuGetConfigForSourceOverrideAsync(sourceOverride, channelName: null, workspace.WorkspaceRoot.FullName, CancellationToken.None));
Assert.False(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config")));
}
[Fact]
public async Task PromptToCreateOrUpdateNuGetConfigAsync_NullChannelName_ShortCircuits()
{
// Null/whitespace channelName must short-circuit without consulting any
// ambient channel source. No exception, no implicit-channel work requested.
var service = CreateService();
await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: null, outputPath: Directory.CreateTempSubdirectory().FullName, CancellationToken.None);
await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "", outputPath: Directory.CreateTempSubdirectory().FullName, CancellationToken.None);
await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: " ", outputPath: Directory.CreateTempSubdirectory().FullName, CancellationToken.None);
}
[Fact]
public async Task CreateOrUpdateNuGetConfigWithoutPromptAsync_NullChannelName_ShortCircuits()
{
var service = CreateService();
var dir = Directory.CreateTempSubdirectory();
try
{
// Null/whitespace inputs must short-circuit and return false without
// resolving a channel from any ambient source.
Assert.False(await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: null, outputPath: dir.FullName, CancellationToken.None));
Assert.False(await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: "", outputPath: dir.FullName, CancellationToken.None));
Assert.False(await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: " ", outputPath: dir.FullName, CancellationToken.None));
}
finally
{
dir.Delete(recursive: true);
}
}
[Fact]
public async Task ResolveTemplatePackageAsync_NullRequestedChannel_UsesImplicitChannelOnly()
{
// No explicit RequestedChannel: the resolver picks the implicit channel only.
// We exercise the production codepath with a tracking packaging service so the
// assertion is that the resolver completes (no exception is thrown by
// an unexpected channel-lookup path) and only the implicit channel is in play.
var requestedChannels = new List<PackageChannelType>();
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, _, _, _) => Task.FromResult(Enumerable.Empty<Aspire.Shared.NuGetPackageCli>())
}, new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh]);
}
};
var service = CreateService(packagingService: packagingService);
var query = new TemplatePackageQuery(
RequestedChannel: null,
VersionOverride: null,
SourceOverride: null,
IncludePrHives: false);
// No packages were staged on the implicit channel, so the resolver throws
// EmptyChoicesException — that's the expected terminal state for "implicit was
// tried, nothing matched". The assertion is that this exception is the one that
// surfaces (not ChannelNotFoundException from a different lookup path).
await Assert.ThrowsAsync<Aspire.Cli.Interaction.EmptyChoicesException>(
async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None));
}
[Fact]
public async Task ResolveTemplatePackage_RequestedChannel_NotFound_Throws()
{
// The resolver no longer special-cases "local". A request for any unrecognized
// channel name — including "local" with no matching named channel — must
// throw ChannelNotFoundException. The local-identity fallback that previously
// lived here has moved to InitCommand so that an explicit `aspire new --channel local`
// without the hive still errors cleanly instead of silently switching feeds.
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh]);
}
};
var service = CreateService(packagingService: packagingService);
var query = new TemplatePackageQuery(
RequestedChannel: PackageChannelNames.Local,
VersionOverride: null,
SourceOverride: null,
IncludePrHives: false);
await Assert.ThrowsAsync<Aspire.Cli.Exceptions.ChannelNotFoundException>(
async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None));
}
[Fact]
public async Task ResolveTemplatePackage_RequestedChannel_Matches_ReturnsThatChannel()
{
// Positive parity: a request for a registered named channel must resolve to that
// exact channel. Verifies the rename + collapsed selection block didn't regress
// the matching path.
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
var stableCh = PackageChannel.CreateExplicitChannel(
"stable",
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", "stable-src")],
new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
[
new Aspire.Shared.NuGetPackageCli { Id = TemplateNuGetConfigService.TemplatesPackageName, Version = "13.3.0", Source = "stable-src" }
])
},
features: new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh, stableCh]);
}
};
var service = CreateService(packagingService: packagingService);
var query = new TemplatePackageQuery(
RequestedChannel: "stable",
VersionOverride: null,
SourceOverride: null,
IncludePrHives: false);
var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
Assert.Equal("stable", selection.Channel.Name);
Assert.Equal(PackageChannelType.Explicit, selection.Channel.Type);
Assert.Equal("13.3.0", selection.Package.Version);
}
[Fact]
public async Task ResolveTemplatePackageAsync_RequestedChannelWithSourceOverride_ReplacesAspireSource()
{
const string channelName = "staging";
const string channelSource = "https://channel.example/v3/index.json";
const string sourceOverride = "https://proxy.example/v3/index.json";
var packageCache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, nugetConfigFile, _) =>
{
Assert.NotNull(nugetConfigFile);
var doc = XDocument.Load(nugetConfigFile.FullName);
var sources = doc.Root!.Element("packageSources")!.Elements("add")
.Select(e => (string)e.Attribute("value")!)
.ToArray();
Assert.Equal([sourceOverride], sources);
Assert.Equal(["Aspire*", PackageMapping.AllPackages], GetPackagePatternsForSource(doc, sourceOverride));
return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
[
new Aspire.Shared.NuGetPackageCli
{
Id = TemplateNuGetConfigService.TemplatesPackageName,
Version = "13.5.0-preview.1",
Source = sourceOverride
}
]);
}
};
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var channel = PackageChannel.CreateExplicitChannel(
channelName,
PackageChannelQuality.Prerelease,
[
new PackageMapping("Aspire*", channelSource),
new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
],
packageCache,
features: new TestFeatures(),
NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([channel]);
}
};
var service = CreateService(packagingService: packagingService);
var query = new TemplatePackageQuery(
RequestedChannel: channelName,
VersionOverride: null,
SourceOverride: sourceOverride,
IncludePrHives: false);
await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
}
[Fact]
public async Task ResolveTemplatePackageAsync_SourceOverrideWithoutRequestedChannel_UsesImplicitChannelOnly()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var hivesDirectory = workspace.CreateDirectory("hives");
hivesDirectory.CreateSubdirectory("pr-12345");
var executionContext = CreateExecutionContextWithHives(workspace.WorkspaceRoot, hivesDirectory);
const string sourceOverride = "https://proxy.example/v3/index.json";
var packageCache = new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, nugetConfigFile, _) =>
{
Assert.NotNull(nugetConfigFile);
var doc = XDocument.Load(nugetConfigFile.FullName);
var sources = doc.Root!.Element("packageSources")!.Elements("add")
.Select(e => (string)e.Attribute("value")!)
.ToArray();
Assert.Equal([sourceOverride], sources);
return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
[
new Aspire.Shared.NuGetPackageCli
{
Id = TemplateNuGetConfigService.TemplatesPackageName,
Version = "13.5.0",
Source = sourceOverride
}
]);
}
};
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var implicitChannel = PackageChannel.CreateImplicitChannel(packageCache, new TestFeatures(), NullLogger.Instance);
var hiveChannel = PackageChannel.CreateExplicitChannel(
"pr-12345",
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", "pr-source")],
new FakeNuGetPackageCache(),
new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "99.0.0");
return Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel, hiveChannel]);
}
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var query = new TemplatePackageQuery(
RequestedChannel: null,
VersionOverride: null,
SourceOverride: sourceOverride,
IncludePrHives: true);
var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
Assert.Equal("13.5.0", selection.Package.Version);
Assert.Equal(sourceOverride, selection.Package.Source);
Assert.Equal(PackageChannelType.Implicit, selection.Channel.Type);
}
[Fact]
public async Task ResolveTemplatePackageAsync_LocalSourceOverrideDoesNotFilterToChannelPin()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagesDirectory = workspace.CreateDirectory("packages");
var nestedDirectory = Directory.CreateDirectory(Path.Combine(
packagesDirectory.FullName,
"aspire.projecttemplates",
"13.5.0"));
File.WriteAllText(
Path.Combine(nestedDirectory.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"),
string.Empty);
var sourceOverride = packagesDirectory.FullName.Replace('\\', '/');
var channel = PackageChannel.CreateExplicitChannel(
"staging",
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", "https://example.invalid/staging/v3/index.json")],
new FakeNuGetPackageCache(),
new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.6.0");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([channel])
};
var service = CreateService(packagingService: packagingService);
var selection = await service.ResolveTemplatePackageAsync(
new TemplatePackageQuery(
RequestedChannel: "staging",
VersionOverride: null,
SourceOverride: sourceOverride,
IncludePrHives: true),
CancellationToken.None);
Assert.Equal("13.5.0", selection.Package.Version);
Assert.Equal(sourceOverride, selection.Package.Source);
}
[Fact]
public async Task InstallTemplatePackageAsync_SourceOverride_UsesExclusiveSource()
{
const string channelSource = "https://channel.example/v3/index.json";
const string sourceOverride = "https://proxy.example/v3/index.json";
const string packageVersion = "13.5.0-preview.1";
var channel = PackageChannel.CreateExplicitChannel(
"staging",
PackageChannelQuality.Prerelease,
[
new PackageMapping("Aspire*", channelSource),
new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
],
new FakeNuGetPackageCache(),
new TestFeatures(),
NullLogger.Instance,
pinnedVersion: packageVersion);
var selection = new TemplatePackageSelection(
new Aspire.Shared.NuGetPackageCli
{
Id = TemplateNuGetConfigService.TemplatesPackageName,
Version = packageVersion,
Source = channelSource
},
channel);
var runner = new TestDotNetCliRunner
{
InstallTemplateAsyncCallback = (packageName, version, nugetConfigFile, nugetSource, _, _, _) =>
{
Assert.Equal(TemplateNuGetConfigService.TemplatesPackageName, packageName);
Assert.Equal(packageVersion, version);
Assert.Equal(sourceOverride, nugetSource);
Assert.NotNull(nugetConfigFile);
var doc = XDocument.Load(nugetConfigFile.FullName);
var sources = doc.Root!.Element("packageSources")!.Elements("add")
.Select(e => (string)e.Attribute("value")!)
.ToArray();
Assert.Equal([sourceOverride], sources);
Assert.Equal(["Aspire*", PackageMapping.AllPackages], GetPackagePatternsForSource(doc, sourceOverride));
return (0, packageVersion);
}
};
var service = CreateService();
var outcome = await service.InstallTemplatePackageAsync(
selection,
sourceOverride,
runner,
"Installing templates",
statusEmoji: null,
CancellationToken.None);
Assert.Equal(0, outcome.ExitCode);
Assert.Equal(packageVersion, outcome.TemplateVersion);
}
[Fact]
public async Task ResolveTemplatePackageAsync_NonExistentRequestedChannel_NotLocal_StillThrowsChannelNotFound()
{
// Companion to RequestedChannel_NotFound_Throws: any unrecognized name (typo
// such as "stalbe") must still surface a ChannelNotFoundException so users see
// the failure instead of silently falling through to the implicit channel.
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh]);
}
};
var service = CreateService(packagingService: packagingService);
var query = new TemplatePackageQuery(
RequestedChannel: "stalbe",
VersionOverride: null,
SourceOverride: null,
IncludePrHives: false);
await Assert.ThrowsAsync<Aspire.Cli.Exceptions.ChannelNotFoundException>(
async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None));
}
[Theory]
// The `aspire new` code path opts into hives via IncludePrHives: true. When a hive
// is actually on disk (GetHiveCount() > 0), the resolver must consider every
// registered channel — and the explicit pr-12345 channel (pinnedVersion 2.0.0) wins
// over the implicit channel (version 1.0.0).
[InlineData(true, true, "2.0.0", false, "pr-12345")]
// Opt-in alone is not enough: the user must also have hive directories on disk
// (GetHiveCount() > 0). Without them, even with IncludePrHives: true the resolver
// must restrict to the implicit channel. This is what protects developers running
// `aspire new` on a clean machine from accidentally pulling from an explicit channel
// that was registered but never installed.
[InlineData(true, false, "1.0.0", true, null)]
// The `aspire init` code path passes IncludePrHives: false intentionally so a
// developer with stale ~/.aspire/hives/* doesn't get a different template than on
// a clean machine. Even with a hive present, the resolver must restrict to the
// implicit channel.
[InlineData(false, true, "1.0.0", true, null)]
public async Task ResolveTemplatePackageAsync_IncludePrHives_RespectsHiveGate(
bool includePrHives,
bool createHiveOnDisk,
string expectedVersion,
bool expectImplicitChannel,
string? expectedChannelName)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var hivesDir = new DirectoryInfo(Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "hives"));
if (createHiveOnDisk)
{
hivesDir.Create();
hivesDir.CreateSubdirectory("pr-12345");
}
var executionContext = CreateExecutionContextWithHives(workspace.WorkspaceRoot, hivesDir);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
[
new Aspire.Shared.NuGetPackageCli { Id = TemplateNuGetConfigService.TemplatesPackageName, Version = "1.0.0", Source = "implicit-src" }
])
}, new TestFeatures(), NullLogger.Instance);
var hiveCh = PackageChannel.CreateExplicitChannel(
"pr-12345",
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", "pr-src")],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "2.0.0");
return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh, hiveCh]);
}
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var query = new TemplatePackageQuery(
RequestedChannel: null,
VersionOverride: null,
SourceOverride: null,
IncludePrHives: includePrHives);
var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
Assert.Equal(expectedVersion, selection.Package.Version);
Assert.Equal(expectImplicitChannel ? PackageChannelType.Implicit : PackageChannelType.Explicit, selection.Channel.Type);
if (expectedChannelName is not null)
{
Assert.Equal(expectedChannelName, selection.Channel.Name);
}
}
[Fact]
public async Task ResolveTemplatePackageAsync_UnqualifiedLocalIdentityWithoutMatchingIdentityChannelPackage_ThrowsActionableError()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var hivesDirectory = workspace.CreateDirectory("hives");
hivesDirectory.CreateSubdirectory("unrelated");
var packagesDirectory = workspace.CreateDirectory("packages");
var prPackagesDirectory = workspace.CreateDirectory("pr-packages");
File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(prPackagesDirectory.FullName, "Aspire.ProjectTemplates.13.6.0-dev.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: PackageChannelNames.Local,
hivesDirectory: hivesDirectory,
identityVersion: "13.6.0-dev");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var implicitChannel = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
[
new Aspire.Shared.NuGetPackageCli
{
Id = TemplateNuGetConfigService.TemplatesPackageName,
Version = "13.5.0",
Source = PackageSources.NuGetOrg
}
])
}, new TestFeatures(), NullLogger.Instance);
var stagingChannel = PackageChannel.CreateExplicitChannel(
"staging",
PackageChannelQuality.Prerelease,
[new PackageMapping("Aspire*", "https://example.invalid/staging/v3/index.json")],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.6.0-dev");
var prChannel = PackageChannel.CreateExplicitChannel(
"pr-12345",
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", prPackagesDirectory.FullName.Replace('\\', '/'))],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.6.0-dev");
var localChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Local,
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.6.0-dev");
return Task.FromResult<IEnumerable<PackageChannel>>([implicitChannel, stagingChannel, prChannel, localChannel]);
}
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var query = new TemplatePackageQuery(
RequestedChannel: null,
VersionOverride: null,
SourceOverride: null,
IncludePrHives: true);
var exception = await Assert.ThrowsAsync<Aspire.Cli.Interaction.EmptyChoicesException>(
async () => await service.ResolveTemplatePackageAsync(query, CancellationToken.None));
Assert.Equal(
string.Format(
CultureInfo.CurrentCulture,
TemplatingStrings.NoMatchingLocalTemplatePackage,
"13.6.0-dev"),
exception.Message);
}
[Fact]
public async Task ResolveTemplatePackageAsync_LocalIdentityWithExplicitLocalChannel_UsesRequestedChannelVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagesDirectory = workspace.CreateDirectory("packages");
File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.6.0.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: PackageChannelNames.Local,
identityVersion: "13.6.0-dev");
var localChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Local,
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.5.0");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([localChannel])
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var selection = await service.ResolveTemplatePackageAsync(
new TemplatePackageQuery(
RequestedChannel: PackageChannelNames.Local,
VersionOverride: null,
SourceOverride: null,
IncludePrHives: true),
CancellationToken.None);
Assert.Equal(PackageChannelNames.Local, selection.Channel.Name);
Assert.Equal("13.5.0", selection.Package.Version);
}
[Fact]
public async Task ResolveTemplatePackageAsync_InitLocalChannelDoesNotFilterToPinnedVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagesDirectory = workspace.CreateDirectory("packages");
var nestedDirectory = Directory.CreateDirectory(Path.Combine(
packagesDirectory.FullName,
"aspire.projecttemplates",
"13.6.0"));
File.WriteAllText(
Path.Combine(nestedDirectory.FullName, "Aspire.ProjectTemplates.13.6.0.nupkg"),
string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: PackageChannelNames.Local,
identityVersion: "13.7.0");
var localChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Local,
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.7.0");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([localChannel])
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var selection = await service.ResolveTemplatePackageAsync(
new TemplatePackageQuery(
RequestedChannel: PackageChannelNames.Local,
VersionOverride: null,
SourceOverride: null,
IncludePrHives: false),
CancellationToken.None);
Assert.Equal("13.6.0", selection.Package.Version);
}
[Fact]
public async Task ResolveTemplatePackageAsync_ExplicitVersionFindsExactLocalPackageBelowNewerPinnedVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagesDirectory = workspace.CreateDirectory("packages");
File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.6.0-dev.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.99.0.0.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: PackageChannelNames.Local,
identityVersion: "13.6.0-dev");
var localChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Local,
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "99.0.0");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([localChannel])
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var selection = await service.ResolveTemplatePackageAsync(
new TemplatePackageQuery(
RequestedChannel: null,
VersionOverride: "13.6.0-dev",
SourceOverride: null,
IncludePrHives: true),
CancellationToken.None);
Assert.Equal("13.6.0-dev", selection.Package.Version);
}
[Fact]
public async Task ResolveTemplatePackageAsync_ExplicitVersionNotFound_ThrowsInsteadOfSelectingAnotherVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagesDirectory = workspace.CreateDirectory("packages");
File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.99.0.0.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: PackageChannelNames.Local,
identityVersion: "13.6.0-dev");
var localChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Local,
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "99.0.0");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([localChannel])
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var exception = await Assert.ThrowsAsync<Aspire.Cli.Interaction.EmptyChoicesException>(
async () => await service.ResolveTemplatePackageAsync(
new TemplatePackageQuery(
RequestedChannel: null,
VersionOverride: "13.5.0-dev",
SourceOverride: null,
IncludePrHives: true),
CancellationToken.None));
Assert.Equal(
string.Format(
CultureInfo.CurrentCulture,
TemplatingStrings.TemplateVersionNotFound,
"13.5.0-dev"),
exception.Message);
}
[Fact]
public async Task ResolveTemplatePackageAsync_UnqualifiedLocalIdentity_PrefersMatchingLocalPackage()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagesDirectory = workspace.CreateDirectory("packages");
File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.6.0-dev.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.7.0-dev.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: PackageChannelNames.Local,
identityVersion: "13.6.0-dev");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var stagingChannel = PackageChannel.CreateExplicitChannel(
"staging",
PackageChannelQuality.Prerelease,
[new PackageMapping("Aspire*", "https://example.invalid/staging/v3/index.json")],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.6.0-dev");
var localChannel = PackageChannel.CreateExplicitChannel(
PackageChannelNames.Local,
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/'))],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.7.0-dev");
return Task.FromResult<IEnumerable<PackageChannel>>([stagingChannel, localChannel]);
}
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var query = new TemplatePackageQuery(
RequestedChannel: null,
VersionOverride: null,
SourceOverride: null,
IncludePrHives: true);
var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
Assert.Equal(PackageChannelNames.Local, selection.Channel.Name);
Assert.True(selection.Channel.IsBackedByLocalPackageDirectory);
Assert.Equal("13.6.0-dev", selection.Package.Version);
}
[Fact]
public async Task ResolveTemplatePackageAsync_LocalIdentityWithExplicitChannel_UsesRequestedChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: PackageChannelNames.Local,
identityVersion: "13.6.0-dev");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var stagingChannel = PackageChannel.CreateExplicitChannel(
"staging",
PackageChannelQuality.Prerelease,
[new PackageMapping("Aspire*", "https://example.invalid/staging/v3/index.json")],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.6.0-dev");
return Task.FromResult<IEnumerable<PackageChannel>>([stagingChannel]);
}
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var query = new TemplatePackageQuery(
RequestedChannel: "staging",
VersionOverride: null,
SourceOverride: null,
IncludePrHives: true);
var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
Assert.Equal("staging", selection.Channel.Name);
Assert.Equal("13.6.0-dev", selection.Package.Version);
}
private static CliExecutionContext CreateExecutionContextWithHives(DirectoryInfo workingDirectory, DirectoryInfo hivesDirectory)
{
return TestExecutionContextHelper.CreateExecutionContext(
workingDirectory,
identityChannel: PackageChannelNames.Daily,
hivesDirectory: hivesDirectory);
}
// Emulating a released build via ASPIRE_CLI_PACKAGES / the sidecar `packages` field: the
// synthesized channel is named after the emulated identity (here "stable", a NON-local-build
// name) and points Aspire.* at a local directory. Even with IncludePrHives:false (the
// `aspire init` shape) and no hive on disk, the deliberate local-packages override must be
// honored so templates resolve from the local directory instead of silently falling back to
// nuget.org. Regression guard for the identity-sidecar emulation bug where a stable/daily/
// staging emulated name caused the local channel to be filtered out of template resolution.
[Fact]
public async Task ResolveTemplatePackageAsync_IdentityPackagesOverride_IncludesLocalChannelEvenWhenPrHivesSuppressed()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagesDir = workspace.CreateDirectory("identity-packages");
File.WriteAllText(Path.Combine(packagesDir.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: "stable",
identityVersion: "13.5.0",
identityOverridden: true,
identityPackagesDirectory: packagesDir);
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) => Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(
[
new Aspire.Shared.NuGetPackageCli { Id = TemplateNuGetConfigService.TemplatesPackageName, Version = "13.4.3", Source = "nuget.org" }
])
}, new TestFeatures(), NullLogger.Instance);
var localStableCh = PackageChannel.CreateExplicitChannel(
"stable",
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", packagesDir.FullName.Replace('\\', '/'))],
new FakeNuGetPackageCache(),
features: new TestFeatures(),
NullLogger.Instance,
pinnedVersion: "13.5.0");
return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh, localStableCh]);
}
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var query = new TemplatePackageQuery(
RequestedChannel: null,
VersionOverride: null,
SourceOverride: null,
IncludePrHives: false);
var selection = await service.ResolveTemplatePackageAsync(query, CancellationToken.None);
Assert.Equal("13.5.0", selection.Package.Version);
Assert.Equal(PackageChannelType.Explicit, selection.Channel.Type);
Assert.Equal("stable", selection.Channel.Name);
}
[Fact]
public async Task ResolveTemplatePackageAsync_ImplicitDiscovery_FindsHierarchicalTemplateFromFileUriMappedLocalChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var packagesDir = workspace.CreateDirectory("local-feed");
var templatePackagePath = Path.Combine(
packagesDir.FullName,
"aspire.projecttemplates",
"13.4.0",
"Aspire.ProjectTemplates.13.4.0.nupkg");
Directory.CreateDirectory(Path.GetDirectoryName(templatePackagePath)!);
await File.WriteAllTextAsync(templatePackagePath, string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
workspace.WorkspaceRoot,
identityChannel: "pr-12345",
identityVersion: "13.4.0");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var implicitCh = PackageChannel.CreateImplicitChannel(new FakeNuGetPackageCache(), new TestFeatures(), NullLogger.Instance);
var localChannel = PackageChannel.CreateExplicitChannel(
"pr-12345",
PackageChannelQuality.Both,
[new PackageMapping("Aspire*", new Uri(packagesDir.FullName).AbsoluteUri)],
new FakeNuGetPackageCache
{
GetTemplatePackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
},
features: new TestFeatures(),
NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([implicitCh, localChannel]);
}
};
var service = CreateService(packagingService: packagingService, executionContext: executionContext);
var selection = await service.ResolveTemplatePackageAsync(
new TemplatePackageQuery(
RequestedChannel: null,
VersionOverride: null,
SourceOverride: null,
IncludePrHives: true),
CancellationToken.None);
Assert.Equal("Aspire.ProjectTemplates", selection.Package.Id);
Assert.Equal("13.4.0", selection.Package.Version);
Assert.Equal(PackageChannelType.Explicit, selection.Channel.Type);
Assert.Equal("pr-12345", selection.Channel.Name);
}
private static string[] GetPackagePatternsForSource(XDocument doc, string source)
{
var packageSourceMapping = doc.Root!.Element("packageSourceMapping");
if (packageSourceMapping is null)
{
return [];
}
return packageSourceMapping
.Elements("packageSource")
.Where(e => string.Equals((string?)e.Attribute("key"), source, StringComparison.OrdinalIgnoreCase))
.Elements("package")
.Select(e => (string?)e.Attribute("pattern"))
.Where(pattern => pattern is not null)
.Select(pattern => pattern!)
.ToArray();
}
[Fact]
public async Task PromptToCreateOrUpdateNuGetConfigAsync_ExplicitChannelRequiringConfig_CreatesConfig()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var outputDir = workspace.WorkspaceRoot.CreateSubdirectory("output");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
// Simulate the daily channel: explicit, custom feed → drops a config.
var dailyCh = PackageChannel.CreateExplicitChannel(
"daily",
PackageChannelQuality.Prerelease,
[
new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"),
new PackageMapping("*", "https://api.nuget.org/v3/index.json")
],
new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([dailyCh]);
}
};
var service = CreateService(packagingService: packagingService);
await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "daily", outputDir.FullName, CancellationToken.None);
// nuget.config should be created because the daily channel routes Aspire* to a custom feed
var configPath = Path.Combine(outputDir.FullName, "nuget.config");
Assert.True(File.Exists(configPath));
var doc = XDocument.Load(configPath);
var sources = doc.Root!.Element("packageSources")!.Elements("add")
.Select(e => (string)e.Attribute("value")!)
.ToArray();
Assert.Equal(
["https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", "https://api.nuget.org/v3/index.json"],
sources);
}
[Fact]
public async Task PromptToCreateOrUpdateNuGetConfigAsync_StableChannel_DoesNotCreateConfig()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var outputDir = workspace.WorkspaceRoot.CreateSubdirectory("output");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
// Simulate the stable channel: explicit but mapped to nuget.org (ambient default),
// so ShouldCreateNuGetConfig() is false (excluded by name).
var stableCh = PackageChannel.CreateExplicitChannel(
"stable",
PackageChannelQuality.Stable,
[new PackageMapping("*", "https://api.nuget.org/v3/index.json")],
new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([stableCh]);
}
};
var service = CreateService(packagingService: packagingService);
await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "stable", outputDir.FullName, CancellationToken.None);
// No nuget.config should be created because the stable channel maps to nuget.org
Assert.False(File.Exists(Path.Combine(outputDir.FullName, "nuget.config")));
}
[Fact]
public async Task PromptToCreateOrUpdateNuGetConfigAsync_StableChannel_UpdatesExistingConfig()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var outputDir = workspace.WorkspaceRoot.CreateSubdirectory("output");
// Pre-existing nuget.config from a previous daily channel
await File.WriteAllTextAsync(
Path.Combine(outputDir.FullName, "nuget.config"),
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="aspire-daily" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="aspire-daily">
<package pattern="Aspire*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var stableCh = PackageChannel.CreateExplicitChannel(
"stable",
PackageChannelQuality.Stable,
[new PackageMapping("*", "https://api.nuget.org/v3/index.json")],
new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([stableCh]);
}
};
var service = CreateService(packagingService: packagingService);
await service.PromptToCreateOrUpdateNuGetConfigAsync(channelName: "stable", outputDir.FullName, CancellationToken.None);
// Existing nuget.config should still exist (it was updated)
var configPath = Path.Combine(outputDir.FullName, "nuget.config");
Assert.True(File.Exists(configPath));
// The stable channel's mapping (* → nuget.org) should now be present
var doc = XDocument.Load(configPath);
var sources = doc.Root!.Element("packageSources")!.Elements("add")
.Select(e => (string)e.Attribute("value")!)
.ToArray();
Assert.Equal(["https://api.nuget.org/v3/index.json"], sources);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CreateOrUpdateNuGetConfigWithoutPromptAsync_StableChannel_RespectsExistingConfig(bool hasExistingConfig)
{
// The "without prompt" path is used by aspire init. When the channel does not
// require a project-level nuget.config (e.g. stable → nuget.org only):
// - No existing config → skip creation entirely (return false)
// - Existing config → update it to clean up stale feeds (return true)
// See: https://github.com/microsoft/aspire/issues/18124
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var outputDir = workspace.WorkspaceRoot.CreateSubdirectory("output");
if (hasExistingConfig)
{
await File.WriteAllTextAsync(
Path.Combine(outputDir.FullName, "nuget.config"),
"""
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="aspire-daily" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
</packageSources>
</configuration>
""");
}
var packagingService = new TestPackagingService
{
GetChannelsAsyncCallback = _ =>
{
var stableCh = PackageChannel.CreateExplicitChannel(
"stable",
PackageChannelQuality.Stable,
[new PackageMapping("*", "https://api.nuget.org/v3/index.json")],
new FakeNuGetPackageCache(),
features: new TestFeatures(), NullLogger.Instance);
return Task.FromResult<IEnumerable<PackageChannel>>([stableCh]);
}
};
var service = CreateService(packagingService: packagingService);
var result = await service.CreateOrUpdateNuGetConfigWithoutPromptAsync(channelName: "stable", outputDir.FullName, CancellationToken.None);
var configPath = Path.Combine(outputDir.FullName, "nuget.config");
if (hasExistingConfig)
{
Assert.True(result);
Assert.True(File.Exists(configPath));
var doc = XDocument.Load(configPath);
var sources = doc.Root!.Element("packageSources")!.Elements("add")
.Select(e => (string)e.Attribute("value")!)
.ToArray();
Assert.Equal(["https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", "https://api.nuget.org/v3/index.json"], sources);
}
else
{
Assert.False(result);
Assert.False(File.Exists(configPath));
}
}
private static TemplateNuGetConfigService CreateService(
TestPackagingService? packagingService = null,
CliExecutionContext? executionContext = null)
{
return new TemplateNuGetConfigService(
new TestInteractionService(),
executionContext ?? TestExecutionContextFactory.CreateTestContext(),
packagingService ?? MockPackagingServiceFactory.Create(),
new StubTemplateVersionPrompter(),
new StubCliHostEnvironment());
}
private sealed class StubTemplateVersionPrompter : Aspire.Cli.Commands.ITemplateVersionPrompter
{
public Task<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> PromptForTemplatesVersionAsync(
IEnumerable<(Aspire.Shared.NuGetPackageCli Package, PackageChannel Channel)> candidatePackages,
CancellationToken cancellationToken)
{
throw new InvalidOperationException(
"TemplateNuGetConfigService unexpectedly entered the version-prompt path; this stub is wired in tests where the prompt should never be reached.");
}
}
private sealed class StubCliHostEnvironment : ICliHostEnvironment
{
public bool SupportsInteractiveInput => false;
public bool SupportsInteractiveOutput => false;
public bool SupportsAnsi => false;
}
}