// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.AspNetCore.InternalTesting;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System.Xml.Linq;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
namespace Aspire.Cli.Tests.Packaging;
public class PackagingServiceTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelDisabled_DoesNotIncludeStagingChannel()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
var configuration = new ConfigurationBuilder().Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var channelNames = channels.Select(c => c.Name).ToList();
Assert.DoesNotContain("staging", channelNames);
Assert.Contains("default", channelNames);
Assert.Contains("stable", channelNames);
Assert.Contains("daily", channelNames);
// Verify that non-staging channels have ConfigureGlobalPackagesFolder = false
var defaultChannel = channels.First(c => c.Name == "default");
Assert.False(defaultChannel.ConfigureGlobalPackagesFolder);
var stableChannel = channels.First(c => c.Name == "stable");
Assert.False(stableChannel.ConfigureGlobalPackagesFolder);
var dailyChannel = channels.First(c => c.Name == "daily");
Assert.False(dailyChannel.ConfigureGlobalPackagesFolder);
}
[Fact]
public async Task GetChannelsAsync_WhenIdentityChannelIsStaging_IncludesStagingChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Staging);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var channelNames = channels.Select(c => c.Name).ToList();
Assert.Contains(PackageChannelNames.Staging, channelNames);
Assert.Equal(
channelNames.IndexOf(PackageChannelNames.Stable),
channelNames.IndexOf(PackageChannelNames.Staging) - 1);
Assert.Equal(
channelNames.IndexOf(PackageChannelNames.Daily),
channelNames.IndexOf(PackageChannelNames.Staging) + 1);
var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging);
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
}
[Fact]
public async Task GetChannelsAsync_WhenIdentityChannelIsStagingOnStableShapedCli_DiscoversMixedPackageQualities()
{
// A stable-shaped staging build publishes a mixed set to one SHA-specific feed: most
// packages are stable, while integrations with SuppressFinalPackageVersion remain
// prerelease. The channel must query both qualities for integration and polyglot-tag
// discovery; changing the generic Stable contract would expose these packages on released
// channels too. Regression test for https://github.com/microsoft/aspire/issues/19423.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
hivesDirectory: hivesDir,
identityChannel: PackageChannelNames.Staging,
identityVersion: "13.5.0");
var stableIntegrationSearches = 0;
var prereleaseIntegrationSearches = 0;
var stableTagSearches = 0;
var prereleaseTagSearches = 0;
var cache = new FakeNuGetPackageCache
{
GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) =>
{
if (prerelease)
{
Interlocked.Increment(ref prereleaseIntegrationSearches);
}
else
{
Interlocked.Increment(ref stableIntegrationSearches);
}
return Task.FromResult<IEnumerable<NuGetPackage>>(
prerelease
? [new() { Id = "Aspire.Hosting.Azure.Kubernetes", Version = "13.5.0-preview.1.26415.2" }]
: [new() { Id = "Aspire.Hosting.Redis", Version = "13.5.0" }]);
},
GetPackagesAsyncCallback = (_, query, _, prerelease, _, _, _) =>
{
Assert.Equal("tags:polyglot", query);
if (prerelease)
{
Interlocked.Increment(ref prereleaseTagSearches);
}
else
{
Interlocked.Increment(ref stableTagSearches);
}
return Task.FromResult<IEnumerable<NuGetPackage>>(
prerelease
? [new() { Id = "Aspire.Hosting.Azure.Kubernetes", Version = "13.5.0-preview.1.26415.2" }]
: [new() { Id = "Aspire.Hosting.Redis", Version = "13.5.0" }]);
}
};
var packagingService = new PackagingService(
executionContext,
cache,
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
cliInformationalVersionProvider: () => "13.5.0+abcdef1234567890abcdef1234567890abcdef12");
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging);
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*");
Assert.Equal(
"https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json",
aspireMapping.Source);
var packages = (await stagingChannel.GetIntegrationPackagesAsync(tempDir, CancellationToken.None).DefaultTimeout())
.OrderBy(package => package.Id, StringComparer.Ordinal)
.ToArray();
var polyglotPackageIds = (await stagingChannel.GetPolyglotCompatiblePackageIdsAsync(tempDir, CancellationToken.None).DefaultTimeout())
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
Assert.Collection(
packages,
package =>
{
Assert.Equal("Aspire.Hosting.Azure.Kubernetes", package.Id);
Assert.Equal("13.5.0-preview.1.26415.2", package.Version);
},
package =>
{
Assert.Equal("Aspire.Hosting.Redis", package.Id);
Assert.Equal("13.5.0", package.Version);
});
Assert.Equal(["Aspire.Hosting.Azure.Kubernetes", "Aspire.Hosting.Redis"], polyglotPackageIds);
Assert.Equal(1, stableIntegrationSearches);
Assert.Equal(1, prereleaseIntegrationSearches);
Assert.Equal(1, stableTagSearches);
Assert.Equal(1, prereleaseTagSearches);
}
[Fact]
public async Task GetChannelsAsync_WhenIdentityChannelIsStagingPrereleaseShaped_RoutesAspirePackagesToDarcFeed()
{
// Reproduces the C# vs polyglot divergence: a staging-identity CLI with a prerelease-shaped
// version (e.g. "13.4.0-preview.1.26280.6") is still an officially published release-branch
// build, so Aspire.* must resolve from its own SHA-specific darc-pub-microsoft-aspire-<commit>
// feed — NOT the shared dnceng/dotnet9 daily feed (which only carries main-branch daily
// packages). Before the fix, useSharedFeed was derived from the version shape (Both quality ->
// shared daily feed), which is what broke `aspire add` for TypeScript apphosts while C#
// apphosts (with the darc feed baked into nuget.config) resolved correctly.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Staging);
// No overrideStagingFeed configured, so the real darc-vs-shared-daily routing is exercised.
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
cliInformationalVersionProvider: () => "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12");
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging);
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*");
Assert.Equal(
"https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json",
aspireMapping.Source);
Assert.DoesNotContain("dotnet9", aspireMapping.Source);
// The darc feed needs an isolated global packages folder, and it carries exactly the build's
// matching packages, so no CLI-version pin is applied.
Assert.True(stagingChannel.ConfigureGlobalPackagesFolder);
Assert.Null(stagingChannel.PinnedVersion);
}
[Fact]
public async Task GetChannelsAsync_WhenIdentityChannelIsStagingStableShaped_RoutesAspirePackagesToDarcFeed()
{
// Regression guard for https://github.com/microsoft/aspire/issues/17527: a stable-shaped
// staging CLI ("13.4.0") must resolve Aspire.* from its SHA-specific darc feed. Its quality
// remains Both because that feed can also contain deliberately prerelease-only packages.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Staging);
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12");
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging);
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*");
Assert.Equal(
"https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json",
aspireMapping.Source);
// Same darc-feed invariants as the prerelease-shaped case: isolated global packages folder
// and no CLI-version pin (the SHA feed already carries exactly the build's packages).
Assert.True(stagingChannel.ConfigureGlobalPackagesFolder);
Assert.Null(stagingChannel.PinnedVersion);
}
[Fact]
public async Task GetChannelsAsync_WhenIdentityChannelIsStagingWithOverrideFeed_UsesOverrideFeed()
{
// An explicit overrideStagingFeed always wins over identity-based darc derivation.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Staging);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json"
})
.Build();
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
configuration,
NullLogger<PackagingService>.Instance,
cliInformationalVersionProvider: () => "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12");
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging);
var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*");
Assert.Equal("https://example.com/nuget/v3/index.json", aspireMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingIdentityCannotDeriveFeedUrl_OmitsChannelAndWarns()
{
// A staging-identity CLI whose informational version carries no '+<commit>' build metadata
// (e.g. an unstamped local/dev build) cannot derive its SHA-specific darc feed, and there is
// no override feed. Synthesis was permitted by the identity gate, so the only safe outcome is
// to omit the staging channel and surface a warning — silently routing to the shared daily
// feed would resolve the wrong (main-branch) packages, which is the bug this PR fixes.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Staging);
var logger = new CapturingLogger<PackagingService>();
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
logger,
cliInformationalVersionProvider: () => "13.4.0-preview.1.26280.6"); // no '+<commit>' build metadata
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name));
// Synthesis was allowed, so the unavailable-reason API has nothing to report — the warning
// is the only diagnostic for this edge case.
Assert.Null(packagingService.GetStagingChannelUnavailableReason());
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("staging feed URL"));
}
public enum ExpectedStagingFeed
{
Absent,
Darc,
Shared,
Override,
}
// Locks the full ShouldUseSharedStagingFeed decision table in one place: feed PROVENANCE is
// identity-driven (staging identity and the Stable-quality feature-flag path -> SHA-specific
// darc feed), while a non-staging identity that opts into staging with Both quality keeps the
// shared dotnet9 daily feed, an explicit override always wins, and an identity with no staging
// opt-in synthesizes no channel at all.
[Theory]
[InlineData(PackageChannelNames.Staging, "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12", false, false, null, ExpectedStagingFeed.Darc, "Both")] // staging identity, prerelease-shaped
[InlineData(PackageChannelNames.Staging, "13.4.0+abcdef1234567890abcdef1234567890abcdef12", false, false, null, ExpectedStagingFeed.Darc, "Both")] // staging identity, stable-shaped
[InlineData(PackageChannelNames.Staging, "13.4.0+abcdef1234567890abcdef1234567890abcdef12", false, false, "https://example.com/o/v3/index.json", ExpectedStagingFeed.Override, "Both")] // override always wins
[InlineData(PackageChannelNames.Stable, "13.4.0+abcdef1234567890abcdef1234567890abcdef12", false, true, null, ExpectedStagingFeed.Shared, "Both")] // stable identity + config channel=staging => Both => shared
[InlineData(PackageChannelNames.Stable, "13.4.0+abcdef1234567890abcdef1234567890abcdef12", true, false, null, ExpectedStagingFeed.Darc, "Stable")] // stable identity + feature flag only => Stable => darc
[InlineData(PackageChannelNames.Daily, "13.4.0+abcdef1234567890abcdef1234567890abcdef12", true, false, null, ExpectedStagingFeed.Darc, "Stable")] // daily identity + feature flag only => Stable => darc
[InlineData(PackageChannelNames.Local, "13.4.0+abcdef1234567890abcdef1234567890abcdef12", false, false, null, ExpectedStagingFeed.Absent, null)] // local identity, no opt-in => no channel
public async Task GetChannelsAsync_StagingFeedRoutingDecisionTable(
string identityChannel,
string informationalVersion,
bool featureEnabled,
bool configChannelStaging,
string? overrideFeed,
ExpectedStagingFeed expected,
string? expectedQuality)
{
const string DarcUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json";
const string SharedUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json";
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: identityChannel);
var settings = new Dictionary<string, string?>();
if (configChannelStaging)
{
settings["channel"] = PackageChannelNames.Staging;
}
if (overrideFeed is not null)
{
settings[PackagingService.OverrideStagingFeedConfigKey] = overrideFeed;
}
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var features = new TestFeatures();
if (featureEnabled)
{
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
}
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
features,
configuration,
NullLogger<PackagingService>.Instance,
cliInformationalVersionProvider: () => informationalVersion);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.SingleOrDefault(c => c.Name == PackageChannelNames.Staging);
if (expected == ExpectedStagingFeed.Absent)
{
Assert.Null(stagingChannel);
return;
}
Assert.NotNull(stagingChannel);
Assert.Equal(Enum.Parse<PackageChannelQuality>(expectedQuality!), stagingChannel.Quality);
var aspireSource = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*").Source;
var expectedSource = expected switch
{
ExpectedStagingFeed.Darc => DarcUrl,
ExpectedStagingFeed.Shared => SharedUrl,
ExpectedStagingFeed.Override => overrideFeed,
_ => throw new InvalidOperationException($"Unexpected expectation: {expected}"),
};
Assert.Equal(expectedSource, aspireSource);
}
// The following tests exercise the diagnostic override mechanism (overrideCliIdentityChannel +
// overrideCliInformationalVersion) end-to-end through the REAL config-reading default providers
// (the seams are intentionally NOT injected), which is exactly the local-validation recipe in
// docs/cli-staging-validation.md. A locally built CLI bakes a 'local' identity, so without the
// overrides these scenarios would never synthesize a staging channel at all.
[Fact]
public async Task GetChannelsAsync_WhenIdentityOverrideAndVersionOverrideSet_RoutesAspirePackagesToDarcFeed()
{
// Full local-validation recipe: a 'local' identity CLI is told (via config overrides) to behave
// like a prerelease-shaped staging build. Both overrides are required — the identity override
// makes ShouldUseSharedStagingFeed pick the darc feed, and the version override supplies the
// '+<commit>' the darc URL is derived from.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Local);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging,
[PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12",
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging);
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*");
Assert.Equal(
"https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json",
aspireMapping.Source);
Assert.DoesNotContain("dotnet9", aspireMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_WhenVersionOverrideIsStableShaped_UsesBothQuality()
{
// Version shape does not narrow an official staging identity: its SHA feed can contain
// stable packages and deliberately prerelease-only integrations from the same build.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Local);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging,
[PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0+abcdef1234567890abcdef1234567890abcdef12",
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging);
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
Assert.Equal(
"https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json",
Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*").Source);
}
[Fact]
public async Task GetChannelsAsync_WhenIdentityOverrideIsInvalid_FallsBackToRealIdentity()
{
// An unrecognized identity override (rejected by IdentityChannelReader.IsValidChannel) is
// ignored and the real 'local' identity is used, so no staging channel is synthesized despite
// the version override being present.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Local);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideCliIdentityChannelConfigKey] = "not-a-real-channel",
[PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12",
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name));
}
[Fact]
public async Task GetChannelsAsync_WhenOverrideStagingFeedSet_WinsOverVersionOverrideDerivation()
{
// overrideStagingFeed is the most powerful escape hatch and must win over the SHA-derived darc
// URL even when the diagnostic version override is also present.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Local);
const string OverrideFeed = "https://example.com/override/v3/index.json";
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging,
[PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12",
[PackagingService.OverrideStagingFeedConfigKey] = OverrideFeed,
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging);
Assert.Equal(OverrideFeed, Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*").Source);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingDiagnosticOverridesActive_EmitsWarning()
{
// Any normal CLI invocation that has the diagnostic overrides set must leave a trace in the
// logs so an overridden identity/feed can't silently resolve Aspire.* packages.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Local);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging,
[PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12",
})
.Build();
var logger = new CapturingLogger<PackagingService>();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, logger);
await packagingService.GetChannelsAsync().DefaultTimeout();
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("diagnostic overrides are active"));
}
[Fact]
public async Task GetChannelsAsync_WhenOnlyVersionOverrideSet_WarnsButSynthesizesNoStagingChannel()
{
// Only the version override is set, so the identity stays 'local' and no staging channel is
// synthesized. The warning must still fire — the override is active even though it had no
// routing effect, and a silent no-op would hide a misconfiguration.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Local);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideCliInformationalVersionConfigKey] = "13.4.0-preview.1.26280.6+abcdef1234567890abcdef1234567890abcdef12",
})
.Build();
var logger = new CapturingLogger<PackagingService>();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, logger);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name));
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("diagnostic overrides are active"));
}
[Fact]
public void GetStagingChannelUnavailableReason_WhenIdentityOverrideIsStaging_ReturnsNull()
{
// The unavailable-reason check (cached via Lazy) must also honor the identity override, so a
// local CLI with overrideCliIdentityChannel=staging reports staging as available.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Local);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideCliIdentityChannelConfigKey] = PackageChannelNames.Staging,
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger<PackagingService>.Instance);
Assert.Null(packagingService.GetStagingChannelUnavailableReason());
}
[Fact]
public async Task GetChannelsAsync_WhenRequestedChannelIsStaging_IncludesStagingChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Stable);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync(requestedChannelName: PackageChannelNames.Staging).DefaultTimeout();
var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging);
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
}
[Fact]
public async Task GetChannelsAsync_WhenConfigurationChannelIsStagingOnLocalCli_DoesNotIncludeStagingChannel()
{
// Regression: https://github.com/microsoft/aspire/issues/16652
// A local/daily/pr-N CLI must not silently fabricate a 'staging' channel from the shared
// daily feed when config asks for staging — that resolves daily packages, not staging.
// The escape hatches (overrideStagingFeed or the staging feature flag) are covered by
// other tests in this file.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["channel"] = PackageChannelNames.Staging
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var channelNames = channels.Select(c => c.Name).ToList();
Assert.DoesNotContain(PackageChannelNames.Staging, channelNames);
var reason = packagingService.GetStagingChannelUnavailableReason();
Assert.NotNull(reason);
Assert.Contains(PackageChannelNames.Local, reason);
}
[Fact]
public async Task GetChannelsAsync_WhenConfigurationChannelIsStagingOnStableCli_IncludesStagingChannelWithSharedFeed()
{
// Counterpart to the local/daily refusal: a stable-identity CLI can synthesize staging
// because the SHA-specific darc feed for the stable commit exists. With quality=Both
// (the default for stagingChannelConfigured), useSharedFeed=true so the channel points
// at the shared dnceng/dotnet9 feed.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Stable);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["channel"] = PackageChannelNames.Staging
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging);
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
Assert.False(stagingChannel.ConfigureGlobalPackagesFolder);
Assert.Contains(stagingChannel.Mappings!, m =>
m.PackageFilter == "Aspire*" &&
m.Source == "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json");
Assert.Null(packagingService.GetStagingChannelUnavailableReason());
}
[Fact]
public async Task GetChannelsAsync_WhenChannelStagingRequestedOnDailyCli_DoesNotIncludeStagingChannel()
{
// Direct repro of https://github.com/microsoft/aspire/issues/16652.
// A daily CLI invoked with `aspire update --channel staging` must NOT synthesize a
// staging channel from either the SHA-specific darc feed (which doesn't exist for daily
// commits) or the shared daily feed (which contains daily packages, not staging).
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Daily);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync(requestedChannelName: PackageChannelNames.Staging).DefaultTimeout();
var channelNames = channels.Select(c => c.Name).ToList();
Assert.DoesNotContain(PackageChannelNames.Staging, channelNames);
var reason = packagingService.GetStagingChannelUnavailableReason();
Assert.NotNull(reason);
Assert.Contains(PackageChannelNames.Daily, reason);
}
[Fact]
public async Task GetChannelsAsync_WhenChannelStagingRequestedOnDailyCliWithOverrideFeed_IncludesStagingChannel()
{
// The overrideStagingFeed escape hatch must still work on a daily CLI: when the user has
// explicitly named the staging feed, we trust them and synthesize the channel.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Daily);
var overrideUrl = "https://example.com/staging/v3/index.json";
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = overrideUrl
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync(requestedChannelName: PackageChannelNames.Staging).DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging);
Assert.Contains(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*" && m.Source == overrideUrl);
Assert.Null(packagingService.GetStagingChannelUnavailableReason());
}
[Theory]
[InlineData(PackageChannelNames.Local)]
[InlineData("pr-12345")]
public async Task GetChannelsAsync_WhenChannelStagingRequestedOnNonReleaseIdentityWithoutOverride_DoesNotIncludeStagingChannel(string identity)
{
// The same gating applies to local and per-PR CLI identities. Per-PR (pr-<N>) builds
// have a hive label baked in by CI but no staging feed of their own.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: identity);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync(requestedChannelName: PackageChannelNames.Staging).DefaultTimeout();
Assert.DoesNotContain(PackageChannelNames.Staging, channels.Select(c => c.Name));
var reason = packagingService.GetStagingChannelUnavailableReason();
Assert.NotNull(reason);
Assert.Contains(identity, reason);
}
[Fact]
public async Task GetChannelsAsync_WhenChannelStagingRequestedOnDailyCliWithFeatureFlag_IncludesStagingChannel()
{
// Back-compat: the StagingChannelEnabled feature flag is an explicit developer/test opt-in
// and continues to bypass the identity gating. The feature-flag-only path defaults the
// synthesized channel quality to Stable, so a non-staging identity routes Aspire.* to the
// SHA-specific darc feed (not the shared daily feed). The informational version is injected
// so the darc derivation is deterministic — no overrideStagingFeed crutch is needed, which
// lets the assertions below isolate the feature-flag gate AND the real feed routing.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: PackageChannelNames.Daily);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
features,
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
cliInformationalVersionProvider: () => "13.4.0+abcdef1234567890abcdef1234567890abcdef12");
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = Assert.Single(channels, c => c.Name == PackageChannelNames.Staging);
Assert.Null(packagingService.GetStagingChannelUnavailableReason());
// Feature-flag-only opt-in => Stable quality => darc feed (the gate alone, with no
// overrideStagingFeed, must both permit synthesis and route to the SHA feed).
Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality);
var aspireMapping = Assert.Single(stagingChannel.Mappings!, m => m.PackageFilter == "Aspire*");
Assert.Equal(
"https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcdef12/nuget/v3/index.json",
aspireMapping.Source);
}
/// <summary>
/// Locks in the structural invariant that <c>aspire init</c> and <c>aspire new</c> depend
/// on: the <c>stable</c> channel is always <see cref="PackageChannelType.Explicit"/> with a
/// non-empty <see cref="PackageChannel.Mappings"/> array containing a <see cref="PackageMapping.AllPackages"/>
/// pattern. <c>TemplateNuGetConfigService.CreateOrUpdateNuGetConfigWithoutPromptAsync</c>
/// short-circuits if the matching channel is not explicit or has no mappings, so a future
/// refactor that flipped stable to implicit / removed its mappings would silently turn the
/// workspace-NuGet.config write into a no-op for every stable-channel CLI user. The
/// InitCommand-level tests use a fake stable channel and cannot catch this regression at the
/// real <see cref="PackagingService"/> layer.
/// </summary>
[Fact]
public async Task GetChannelsAsync_StableChannel_IsExplicitWithAllPackagesMappingToNuGetOrg()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
var configuration = new ConfigurationBuilder().Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stableChannel = channels.First(c => c.Name == PackageChannelNames.Stable);
Assert.Equal(PackageChannelType.Explicit, stableChannel.Type);
Assert.NotNull(stableChannel.Mappings);
Assert.NotEmpty(stableChannel.Mappings!);
Assert.Contains(stableChannel.Mappings!, m =>
m.PackageFilter == PackageMapping.AllPackages &&
m.Source == PackageSources.NuGetOrg);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelEnabled_IncludesStagingChannelWithOverrideFeed()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var testFeedUrl = "https://example.com/nuget/v3/index.json";
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = testFeedUrl
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var channelNames = channels.Select(c => c.Name).ToList();
Assert.Contains("staging", channelNames);
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality);
Assert.True(stagingChannel.ConfigureGlobalPackagesFolder);
Assert.NotNull(stagingChannel.Mappings);
var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*");
Assert.NotNull(aspireMapping);
Assert.Equal(testFeedUrl, aspireMapping.Source);
var nugetMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "*");
Assert.NotNull(nugetMapping);
Assert.Equal(PackageSources.NuGetOrg, nugetMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelEnabledWithOverrideFeed_UsesFullUrl()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var customFeedUrl = "https://custom-feed.example.com/v3/index.json";
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = customFeedUrl
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*");
Assert.NotNull(aspireMapping);
Assert.Equal(customFeedUrl, aspireMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelEnabledWithAzureDevOpsFeedOverride_UsesFullUrl()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var azureDevOpsFeedUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abcd1234/nuget/v3/index.json";
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = azureDevOpsFeedUrl
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*");
Assert.NotNull(aspireMapping);
Assert.Equal(azureDevOpsFeedUrl, aspireMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidOverrideFeed_FallsBackToDefault()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var invalidFeedUrl = "not-a-valid-url";
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = invalidFeedUrl
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
// When invalid URL is provided, staging channel should not be created (falls back to default behavior which returns null)
var channelNames = channels.Select(c => c.Name).ToList();
Assert.DoesNotContain("staging", channelNames);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityOverride_UsesSpecifiedQuality()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json",
["overrideStagingQuality"] = "Prerelease"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelEnabledWithQualityBoth_UsesQualityBoth()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json",
["overrideStagingQuality"] = "Both"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelEnabledWithInvalidQuality_DefaultsToStable()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json",
["overrideStagingQuality"] = "InvalidValue"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelEnabledWithoutQualityOverride_DefaultsToStable()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality);
}
[Fact]
public async Task NuGetConfigMerger_WhenChannelRequiresGlobalPackagesFolder_AddsGlobalPackagesFolderConfiguration()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-48a11dae/nuget/v3/index.json"
})
.Build();
var packagingService = new PackagingService(
TestExecutionContextHelper.CreateExecutionContext(tempDir),
new FakeNuGetPackageCache(),
features,
configuration,
NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == "staging");
// Act
await NuGetConfigMerger.CreateOrUpdateAsync(tempDir, stagingChannel).DefaultTimeout();
// Assert
var nugetConfigPath = Path.Combine(tempDir.FullName, "nuget.config");
Assert.True(File.Exists(nugetConfigPath));
var configContent = await File.ReadAllTextAsync(nugetConfigPath);
Assert.Contains("globalPackagesFolder", configContent);
Assert.Contains(".nugetpackages", configContent);
// Verify the XML structure
var doc = XDocument.Load(nugetConfigPath);
var configSection = doc.Root?.Element("config");
Assert.NotNull(configSection);
var globalPackagesFolderAdd = configSection.Elements("add")
.FirstOrDefault(add => string.Equals((string?)add.Attribute("key"), "globalPackagesFolder", StringComparison.OrdinalIgnoreCase));
Assert.NotNull(globalPackagesFolderAdd);
var actualGlobalPackagesFolder = (string?)globalPackagesFolderAdd.Attribute("value");
Assert.Equal(".nugetpackages", actualGlobalPackagesFolder);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelEnabled_StagingAppearsAfterStableBeforeDaily()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
// Create some PR hives to ensure staging appears before them
hivesDir.Create();
Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-10167"));
Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-11832"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var channelNames = channels.Select(c => c.Name).ToList();
// Verify all expected channels are present
Assert.Contains("default", channelNames);
Assert.Contains("stable", channelNames);
Assert.Contains("staging", channelNames);
Assert.Contains("daily", channelNames);
Assert.Contains("pr-10167", channelNames);
Assert.Contains("pr-11832", channelNames);
// Verify the order: default, stable, staging, daily, pr-*
var defaultIndex = channelNames.IndexOf("default");
var stableIndex = channelNames.IndexOf("stable");
var stagingIndex = channelNames.IndexOf("staging");
var dailyIndex = channelNames.IndexOf("daily");
var pr10167Index = channelNames.IndexOf("pr-10167");
var pr11832Index = channelNames.IndexOf("pr-11832");
Assert.True(defaultIndex < stableIndex, $"default should come before stable (default: {defaultIndex}, stable: {stableIndex})");
Assert.True(stableIndex < stagingIndex, $"stable should come before staging (stable: {stableIndex}, staging: {stagingIndex})");
Assert.True(stagingIndex < dailyIndex, $"staging should come before daily (staging: {stagingIndex}, daily: {dailyIndex})");
Assert.True(dailyIndex < pr10167Index, $"daily should come before pr-10167 (daily: {dailyIndex}, pr-10167: {pr10167Index})");
Assert.True(dailyIndex < pr11832Index, $"daily should come before pr-11832 (daily: {dailyIndex}, pr-11832: {pr11832Index})");
}
[Fact]
public async Task GetChannelsAsync_WhenStagingChannelDisabled_OrderIsDefaultStableDailyPr()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
// Create some PR hives
hivesDir.Create();
Directory.CreateDirectory(Path.Combine(hivesDir.FullName, "pr-12345"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
// Staging disabled by default
var configuration = new ConfigurationBuilder().Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var channelNames = channels.Select(c => c.Name).ToList();
// Verify staging is not present
Assert.DoesNotContain("staging", channelNames);
// Verify the order: default, stable, daily, pr-*
var defaultIndex = channelNames.IndexOf("default");
var stableIndex = channelNames.IndexOf("stable");
var dailyIndex = channelNames.IndexOf("daily");
var pr12345Index = channelNames.IndexOf("pr-12345");
Assert.True(defaultIndex < stableIndex, "default should come before stable");
Assert.True(stableIndex < dailyIndex, "stable should come before daily");
Assert.True(dailyIndex < pr12345Index, "daily should come before pr-12345");
}
[Fact]
public async Task GetChannelsAsync_WhenStagingQualityPrerelease_AndNoFeedOverride_UsesSharedFeed()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
// Set quality to Prerelease but do NOT set overrideStagingFeed
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["overrideStagingQuality"] = "Prerelease"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality);
Assert.False(stagingChannel.ConfigureGlobalPackagesFolder);
var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*");
Assert.NotNull(aspireMapping);
Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingQualityBoth_AndNoFeedOverride_UsesSharedFeed()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
// Set quality to Both but do NOT set overrideStagingFeed
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["overrideStagingQuality"] = "Both"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality);
Assert.False(stagingChannel.ConfigureGlobalPackagesFolder);
var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*");
Assert.NotNull(aspireMapping);
Assert.Equal("https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json", aspireMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingQualityPrerelease_WithFeedOverride_UsesFeedOverride()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
// Set both quality override AND feed override — feed override should win
var customFeed = "https://custom-feed.example.com/v3/index.json";
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["overrideStagingQuality"] = "Prerelease",
[PackagingService.OverrideStagingFeedConfigKey] = customFeed
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.Equal(PackageChannelQuality.Prerelease, stagingChannel.Quality);
// When an explicit feed override is provided, globalPackagesFolder stays enabled
Assert.True(stagingChannel.ConfigureGlobalPackagesFolder);
var aspireMapping = stagingChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*");
Assert.NotNull(aspireMapping);
Assert.Equal(customFeed, aspireMapping.Source);
}
[Fact]
public async Task NuGetConfigMerger_WhenStagingUsesSharedFeed_DoesNotAddGlobalPackagesFolder()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
// Quality=Prerelease with no feed override → shared feed mode
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["overrideStagingQuality"] = "Prerelease"
})
.Build();
var packagingService = new PackagingService(
TestExecutionContextHelper.CreateExecutionContext(tempDir),
new FakeNuGetPackageCache(),
features,
configuration,
NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == "staging");
// Act
await NuGetConfigMerger.CreateOrUpdateAsync(tempDir, stagingChannel).DefaultTimeout();
// Assert
var nugetConfigPath = Path.Combine(tempDir.FullName, "nuget.config");
Assert.True(File.Exists(nugetConfigPath));
var configContent = await File.ReadAllTextAsync(nugetConfigPath);
Assert.DoesNotContain("globalPackagesFolder", configContent);
Assert.DoesNotContain(".nugetpackages", configContent);
// Verify it still has the shared feed URL
Assert.Contains("dotnet9", configContent);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingPinToCliVersionSet_ChannelHasPinnedVersion()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["overrideStagingQuality"] = "Prerelease",
["stagingPinToCliVersion"] = "true"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.NotNull(stagingChannel.PinnedVersion);
// Should not contain build metadata (+hash)
Assert.DoesNotContain("+", stagingChannel.PinnedVersion);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingPinToCliVersionNotSet_ChannelHasNoPinnedVersion()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["overrideStagingQuality"] = "Prerelease"
// No stagingPinToCliVersion
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
Assert.Null(stagingChannel.PinnedVersion);
}
[Fact]
public async Task GetChannelsAsync_WhenStagingPinToCliVersionSetButNotSharedFeed_ChannelHasNoPinnedVersion()
{
// Arrange - pin is set but explicit feed override means not using shared feed
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json",
["stagingPinToCliVersion"] = "true"
})
.Build();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var stagingChannel = channels.First(c => c.Name == "staging");
// With explicit feed override, useSharedFeed is false, so pinning is not activated
Assert.Null(stagingChannel.PinnedVersion);
}
[Fact]
public async Task GetChannelsAsync_WhenLocalHiveContainsProjectTemplatesPackage_ChannelHasPinnedVersion()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, "local", "packages"));
localPackagesDir.Create();
const string localVersion = "13.3.0-local.20260413.t002308";
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.ProjectTemplates.{localVersion}.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.{localVersion}.nupkg"), string.Empty);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert
var localChannel = channels.First(c => c.Name == "local");
Assert.Equal(localVersion, localChannel.PinnedVersion);
}
[Fact]
public async Task GetChannelsAsync_WhenPrIdentityRunsFromDogfoodInstallPrefix_AddsMatchingPrHiveChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
const string prChannelName = "pr-17225";
const string prVersion = "13.4.0-pr.17225.g1234567";
var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix"));
var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire");
Directory.CreateDirectory(Path.GetDirectoryName(processPath)!);
File.WriteAllText(processPath, string.Empty);
var packagesDirectory = Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages"));
File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{prVersion}.nupkg"), string.Empty);
// Deliberately point the execution context at the default Aspire home, not the custom
// PR install prefix. This is the dogfood acquisition shape that previously made a
// PR-acquired CLI fall back to normal channels unless the user passed --source.
var defaultHivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
hivesDirectory: defaultHivesDir,
identityChannel: prChannelName);
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
processPathProvider: () => processPath);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var prChannel = Assert.Single(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase));
Assert.Equal(prVersion, prChannel.PinnedVersion);
Assert.Contains(prChannel.Mappings!, mapping =>
mapping.PackageFilter == "Aspire*" &&
mapping.Source == packagesDirectory.FullName.Replace('\\', '/'));
}
[Fact]
public async Task GetChannelsAsync_WhenPrIdentityExistsInDefaultHiveAndDogfoodInstallPrefix_UsesDogfoodHiveChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
const string prChannelName = "pr-17225";
const string defaultHiveVersion = "13.4.0-pr.17225.g1111111";
const string dogfoodHiveVersion = "13.4.0-pr.17225.g2222222";
var defaultHivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var defaultPackagesDirectory = Directory.CreateDirectory(Path.Combine(defaultHivesDir.FullName, prChannelName, "packages"));
File.WriteAllText(Path.Combine(defaultPackagesDirectory.FullName, $"Aspire.ProjectTemplates.{defaultHiveVersion}.nupkg"), string.Empty);
var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix"));
var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire");
Directory.CreateDirectory(Path.GetDirectoryName(processPath)!);
File.WriteAllText(processPath, string.Empty);
var dogfoodPackagesDirectory = Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages"));
File.WriteAllText(Path.Combine(dogfoodPackagesDirectory.FullName, $"Aspire.ProjectTemplates.{dogfoodHiveVersion}.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
hivesDirectory: defaultHivesDir,
identityChannel: prChannelName);
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
processPathProvider: () => processPath);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var prChannel = Assert.Single(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase));
Assert.Equal(dogfoodHiveVersion, prChannel.PinnedVersion);
Assert.Contains(prChannel.Mappings!, mapping =>
mapping.PackageFilter == "Aspire*" &&
mapping.Source == dogfoodPackagesDirectory.FullName.Replace('\\', '/'));
}
[Fact]
public async Task GetChannelsAsync_WhenPrDogfoodHiveHasOnlyMalformedPackageNames_AddsChannelWithoutPinnedVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
const string prChannelName = "pr-17225";
var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix"));
var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire");
Directory.CreateDirectory(Path.GetDirectoryName(processPath)!);
File.WriteAllText(processPath, string.Empty);
var packagesDirectory = Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages"));
File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.not-a-semver.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")),
identityChannel: prChannelName);
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
processPathProvider: () => processPath);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var prChannel = Assert.Single(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase));
Assert.Null(prChannel.PinnedVersion);
Assert.Contains(prChannel.Mappings!, mapping =>
mapping.PackageFilter == "Aspire*" &&
mapping.Source == packagesDirectory.FullName.Replace('\\', '/'));
}
[Fact]
public async Task GetChannelsAsync_WhenPrIdentityDogfoodPackagesDirectoryIsMissing_DoesNotAddPrHiveChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
const string prChannelName = "pr-17225";
var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix"));
var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire");
Directory.CreateDirectory(Path.GetDirectoryName(processPath)!);
File.WriteAllText(processPath, string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")),
identityChannel: prChannelName);
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
processPathProvider: () => processPath);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
Assert.DoesNotContain(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GetChannelsAsync_WhenPrIdentityDoesNotMatchDogfoodDirectory_DoesNotAddPrHiveChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
const string installedPrChannelName = "pr-11111";
const string identityPrChannelName = "pr-22222";
var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix"));
var processPath = Path.Combine(installPrefix.FullName, "dogfood", installedPrChannelName, "bin", "aspire");
Directory.CreateDirectory(Path.GetDirectoryName(processPath)!);
File.WriteAllText(processPath, string.Empty);
Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", identityPrChannelName, "packages"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")),
identityChannel: identityPrChannelName);
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
processPathProvider: () => processPath);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
Assert.DoesNotContain(channels, c => string.Equals(c.Name, identityPrChannelName, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GetChannelsAsync_WhenProcessPathProviderThrows_DoesNotAddPrHiveChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
const string prChannelName = "pr-17225";
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")),
identityChannel: prChannelName);
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
processPathProvider: () => throw new IOException("Process path unavailable."));
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
Assert.DoesNotContain(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase));
Assert.Contains(channels, c => c.Name == PackageChannelNames.Stable);
Assert.Contains(channels, c => c.Name == PackageChannelNames.Daily);
}
[Fact]
public async Task GetChannelsAsync_WhenNonPrIdentityRunsFromDogfoodInstallPrefix_DoesNotAddPrHiveChannel()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
const string prChannelName = "pr-17225";
var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix"));
var processPath = Path.Combine(installPrefix.FullName, "dogfood", prChannelName, "bin", "aspire");
Directory.CreateDirectory(Path.GetDirectoryName(processPath)!);
File.WriteAllText(processPath, string.Empty);
Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
hivesDirectory: new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")),
identityChannel: PackageChannelNames.Daily);
var packagingService = new PackagingService(
executionContext,
new FakeNuGetPackageCache(),
new TestFeatures(),
new ConfigurationBuilder().Build(),
NullLogger<PackagingService>.Instance,
processPathProvider: () => processPath);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
Assert.DoesNotContain(channels, c => string.Equals(c.Name, prChannelName, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void TryResolvePrInstallPackagesDirectory_WithMalformedProcessPath_ReturnsNull()
{
Assert.Null(PackagingService.TryResolvePrInstallPackagesDirectory("bad\0path", "pr-17225"));
}
[Fact]
public void TryResolvePrInstallPackagesDirectory_WithWrongInstallLayout_ReturnsNull()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
const string prChannelName = "pr-17225";
var installPrefix = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "custom-aspire-prefix"));
var processPath = Path.Combine(installPrefix.FullName, "bin", "aspire");
Directory.CreateDirectory(Path.GetDirectoryName(processPath)!);
File.WriteAllText(processPath, string.Empty);
Directory.CreateDirectory(Path.Combine(installPrefix.FullName, "hives", prChannelName, "packages"));
Assert.Null(PackagingService.TryResolvePrInstallPackagesDirectory(processPath, prChannelName));
}
[Fact]
public async Task LocalHiveChannel_WithPinnedVersion_ReturnsSyntheticTemplatePackage()
{
// Arrange - simulate package search returning a mismatched stable version
var fakeCache = new FakeNuGetPackageCacheWithPackages(
[
new() { Id = "Aspire.ProjectTemplates", Version = "13.2.2", Source = PackageSources.NuGetOrg },
]);
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, "local", "packages"));
localPackagesDir.Create();
const string localVersion = "13.3.0-local.20260413.t002308";
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.ProjectTemplates.{localVersion}.nupkg"), string.Empty);
var packagingService = new PackagingService(executionContext, fakeCache, new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var localChannel = channels.First(c => c.Name == "local");
var templatePackages = await localChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout();
// Assert
var package = Assert.Single(templatePackages);
Assert.Equal("Aspire.ProjectTemplates", package.Id);
Assert.Equal(localVersion, package.Version);
Assert.Equal(localPackagesDir.FullName.Replace('\\', '/'), package.Source);
}
/// <summary>
/// Verifies that when pinned to CLI version, GetTemplatePackagesAsync returns a synthetic result
/// with the pinned version, bypassing actual NuGet search.
/// </summary>
[Fact]
public async Task StagingChannel_WithPinnedVersion_ReturnsSyntheticTemplatePackage()
{
// Arrange - simulate a shared feed that has packages from both 13.2 and 13.3 version lines
var fakeCache = new FakeNuGetPackageCacheWithPackages(
[
new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" },
new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26200.5", Source = "dotnet9" },
new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" },
new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26110.3", Source = "dotnet9" },
new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" },
]);
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["overrideStagingQuality"] = "Prerelease",
["stagingPinToCliVersion"] = "true"
})
.Build();
var packagingService = new PackagingService(executionContext, fakeCache, features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == "staging");
var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout();
// Assert - should return exactly one synthetic package with the CLI's pinned version
var packageList = templatePackages.ToList();
outputHelper.WriteLine($"Template packages returned: {packageList.Count}");
foreach (var p in packageList)
{
outputHelper.WriteLine($" {p.Id} {p.Version}");
}
Assert.Single(packageList);
Assert.Equal("Aspire.ProjectTemplates", packageList[0].Id);
Assert.Equal(stagingChannel.PinnedVersion, packageList[0].Version);
// Pinned version should not contain build metadata
Assert.DoesNotContain("+", packageList[0].Version!);
}
/// <summary>
/// Verifies that when pinned to CLI version, GetIntegrationPackagesAsync discovers packages
/// from the feed but overrides their version to the pinned version.
/// </summary>
[Fact]
public async Task StagingChannel_WithPinnedVersion_OverridesIntegrationPackageVersions()
{
// Arrange - integration packages with various versions
var fakeCache = new FakeNuGetPackageCacheWithPackages(
[
new() { Id = "Aspire.Hosting.Redis", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" },
new() { Id = "Aspire.Hosting.PostgreSQL", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" },
]);
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["overrideStagingQuality"] = "Prerelease",
["stagingPinToCliVersion"] = "true"
})
.Build();
var packagingService = new PackagingService(executionContext, fakeCache, features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == "staging");
var integrationPackages = await stagingChannel.GetIntegrationPackagesAsync(tempDir, CancellationToken.None).DefaultTimeout();
// Assert - should discover both packages but with pinned version
var packageList = integrationPackages.ToList();
outputHelper.WriteLine($"Integration packages returned: {packageList.Count}");
foreach (var p in packageList)
{
outputHelper.WriteLine($" {p.Id} {p.Version}");
}
Assert.Equal(2, packageList.Count);
Assert.All(packageList, p => Assert.Equal(stagingChannel.PinnedVersion, p.Version));
Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.Redis");
Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.PostgreSQL");
}
/// <summary>
/// Verifies that without pinning, all prerelease packages from the feed are returned as-is.
/// </summary>
[Fact]
public async Task StagingChannel_WithoutPinnedVersion_ReturnsAllPrereleasePackages()
{
// Arrange
var fakeCache = new FakeNuGetPackageCacheWithPackages(
[
new() { Id = "Aspire.ProjectTemplates", Version = "13.3.0-preview.1.26201.1", Source = "dotnet9" },
new() { Id = "Aspire.ProjectTemplates", Version = "13.2.0-preview.1.26111.6", Source = "dotnet9" },
new() { Id = "Aspire.ProjectTemplates", Version = "13.1.0", Source = "dotnet9" },
]);
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var features = new TestFeatures();
features.SetFeature(KnownFeatures.StagingChannelEnabled, true);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["overrideStagingQuality"] = "Prerelease"
// No stagingPinToCliVersion — should return all prerelease
})
.Build();
var packagingService = new PackagingService(executionContext, fakeCache, features, configuration, NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var stagingChannel = channels.First(c => c.Name == "staging");
var templatePackages = await stagingChannel.GetTemplatePackagesAsync(tempDir, CancellationToken.None).DefaultTimeout();
// Assert
var packageList = templatePackages.ToList();
outputHelper.WriteLine($"Template packages returned: {packageList.Count}");
foreach (var p in packageList)
{
outputHelper.WriteLine($" {p.Id} {p.Version}");
}
// Should return only the prerelease ones (quality filter), but both 13.3 and 13.2
Assert.Equal(2, packageList.Count);
Assert.Contains(packageList, p => p.Version!.StartsWith("13.3"));
Assert.Contains(packageList, p => p.Version!.StartsWith("13.2"));
}
/// <summary>
/// Verifies that hive channel names always match their directory name regardless of
/// the CLI identity channel. PackagingService no longer renames hive directories
/// in-memory; the script writes the hive as "local" directly.
/// </summary>
[Fact]
public async Task GetChannelsAsync_HiveChannelNameAlwaysMatchesDirectoryName()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
// Hive directory named "local" (as written by get-aspire-cli-pr.sh --local-dir)
const string localHiveName = "local";
var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, localHiveName, "packages"));
localPackagesDir.Create();
const string pinnedVersion = "13.4.0-pr.16820.g1a99aa46";
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.ProjectTemplates.{pinnedVersion}.nupkg"), string.Empty);
// CLI binary built with AspireCliChannel=local
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: "local");
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert: channel name == directory name (no in-memory rename)
var localChannel = channels.FirstOrDefault(c => c.Name == localHiveName);
Assert.NotNull(localChannel);
Assert.Equal(pinnedVersion, localChannel.PinnedVersion);
}
/// <summary>
/// Verifies that when the CLI identity channel is NOT "local" (e.g. "daily"),
/// hive channels keep their directory-derived names.
/// </summary>
[Fact]
public async Task GetChannelsAsync_WhenIdentityChannelIsNotLocal_HiveKeepsDirectoryName()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
const string runHiveName = "run-99999";
var runPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, runHiveName, "packages"));
runPackagesDir.Create();
const string pinnedVersion = "13.4.0-pr.16820.g1a99aa46";
File.WriteAllText(Path.Combine(runPackagesDir.FullName, $"Aspire.ProjectTemplates.{pinnedVersion}.nupkg"), string.Empty);
// CLI binary built with non-local channel ("daily") — explicit so the test stays
// accurate even as the CliExecutionContext default channel evolves.
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir, identityChannel: "daily");
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
// Assert: the hive channel keeps its directory name
var hiveChannel = channels.FirstOrDefault(c => c.Name == runHiveName);
Assert.NotNull(hiveChannel);
}
/// <summary>
/// Verifies that for a local hive channel with a pinned version, GetIntegrationPackagesAsync
/// enumerates .nupkg files directly from the local folder and returns Aspire.Hosting.* integration
/// packages without calling dotnet package search (which does not support local folder sources).
/// </summary>
[Fact]
public async Task LocalHiveChannel_WithPinnedVersion_ReturnsIntegrationPackagesFromNupkgFiles()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, "local", "packages"));
localPackagesDir.Create();
const string localVersion = "13.4.0-pr.16820.g1a99aa46";
// Root hosting package that should not appear in integration search
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.{localVersion}.nupkg"), string.Empty);
// Hosting integration packages that should be returned
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.Redis.{localVersion}.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.Hosting.JavaScript.{localVersion}.nupkg"), string.Empty);
// Non-hosting packages that should NOT be returned by GetIntegrationPackagesAsync
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.ProjectTemplates.{localVersion}.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(localPackagesDir.FullName, $"Aspire.AppHost.Sdk.{localVersion}.nupkg"), string.Empty);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
// Act
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var localChannel = channels.First(c => c.Name == "local");
var integrationPackages = await localChannel.GetIntegrationPackagesAsync(tempDir, CancellationToken.None).DefaultTimeout();
// Assert
var packageList = integrationPackages.ToList();
Assert.Equal(2, packageList.Count);
Assert.All(packageList, p => Assert.Equal(localVersion, p.Version));
Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.Redis");
Assert.Contains(packageList, p => p.Id == "Aspire.Hosting.JavaScript");
// Non-hosting packages must not appear
Assert.DoesNotContain(packageList, p => p.Id == "Aspire.Hosting");
Assert.DoesNotContain(packageList, p => p.Id == "Aspire.ProjectTemplates");
Assert.DoesNotContain(packageList, p => p.Id == "Aspire.AppHost.Sdk");
}
[Fact]
public async Task GetChannelsAsync_LocalHive_AspireMappingPointsAtLocalDirectory_NotPublicFeed()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, PackageChannelNames.Local, "packages"));
localPackagesDir.Create();
File.WriteAllText(Path.Combine(localPackagesDir.FullName, "Aspire.Hosting.13.4.0-pr.16820.g1a99aa46.nupkg"), string.Empty);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var localChannel = channels.First(c => c.Name == PackageChannelNames.Local);
Assert.NotNull(localChannel.Mappings);
var aspireMapping = localChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*");
Assert.NotNull(aspireMapping);
var expectedLocalPath = localPackagesDir.FullName.Replace('\\', '/');
Assert.Equal(expectedLocalPath, aspireMapping.Source);
Assert.False(UrlHelper.IsHttpUrl(aspireMapping.Source), "Local hive Aspire* mapping must be a filesystem path, not an HTTP feed.");
var fallbackMapping = localChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == PackageMapping.AllPackages);
Assert.NotNull(fallbackMapping);
Assert.Equal(PackageSources.NuGetOrg, fallbackMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_LocalHive_EmptyDirectory_ReturnsChannelWithNullPinnedVersion()
{
// Partial-state shape: the user has `~/.aspire/hives/local/` on disk but no
// packages have been deposited yet (e.g., a partially-completed local build, or an
// install script that created the layout but failed before staging packages).
// Pinning behavior:
// - GetChannelsAsync still produces a `local` channel because GetDirectories() sees the dir.
// - GetLocalHivePinnedVersion returns null (no Aspire.ProjectTemplates / Aspire.Hosting /
// Aspire.AppHost.Sdk nupkgs to derive a version from), so the channel's PinnedVersion
// is null. Downstream, PackageChannel.GetIntegrationPackagesAsync only takes the
// direct-enumeration shortcut when PinnedVersion != null, so an empty local hive
// falls through to the standard NuGet search path with the local dir as a source.
// This test pins the channel-construction half of that contract.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
// Create the hive root but no `packages/` subdir — directory-exists-but-unpopulated.
var localHiveDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, PackageChannelNames.Local));
localHiveDir.Create();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var localChannel = channels.FirstOrDefault(c => c.Name == PackageChannelNames.Local);
Assert.NotNull(localChannel);
Assert.Null(localChannel.PinnedVersion);
Assert.NotNull(localChannel.Mappings);
var aspireMapping = localChannel.Mappings!.FirstOrDefault(m => m.PackageFilter == "Aspire*");
Assert.NotNull(aspireMapping);
// Mapping still points at the (non-existent) packages dir; PackageChannel guards on
// Directory.Exists before enumerating, so this is safe at downstream call time.
var expectedLocalPackagesPath = Path.Combine(localHiveDir.FullName, "packages").Replace('\\', '/');
Assert.Equal(expectedLocalPackagesPath, aspireMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_LocalHive_EmptyPackagesDirectory_ReturnsChannelWithNullPinnedVersion()
{
// Partial-state variant: `~/.aspire/hives/local/packages/` exists but contains zero `*.nupkg` files.
// GetLocalHivePinnedVersion returns null because no FindHighestVersion lookup matches.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(tempDir, hivesDirectory: hivesDir);
var localPackagesDir = new DirectoryInfo(Path.Combine(hivesDir.FullName, PackageChannelNames.Local, "packages"));
localPackagesDir.Create();
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var localChannel = channels.FirstOrDefault(c => c.Name == PackageChannelNames.Local);
Assert.NotNull(localChannel);
Assert.Null(localChannel.PinnedVersion);
}
[Fact]
public async Task GetChannelsAsync_WhenPackagesOverrideSet_RegistersChannelMappedToOverrideDirectory()
{
// ASPIRE_CLI_PACKAGES points the Aspire* feed at a flat .nupkg directory and the service
// synthesizes a channel named after the running CLI's identity channel that maps Aspire* there
// and pins to the version discovered in the directory.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var packagesOverrideDir = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "shipping"));
const string overrideVersion = "13.5.0-preview.1.26310.9";
File.WriteAllText(Path.Combine(packagesOverrideDir.FullName, $"Aspire.ProjectTemplates.{overrideVersion}.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(packagesOverrideDir.FullName, $"Aspire.Hosting.{overrideVersion}.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
identityChannel: PackageChannelNames.Daily,
hivesDirectory: hivesDir,
identityPackagesDirectory: packagesOverrideDir);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var overrideChannel = Assert.Single(channels, c => string.Equals(c.Name, PackageChannelNames.Daily, StringComparison.OrdinalIgnoreCase));
Assert.Equal(overrideVersion, overrideChannel.PinnedVersion);
var aspireMapping = Assert.Single(overrideChannel.Mappings!, m => m.PackageFilter == "Aspire*");
Assert.Equal(packagesOverrideDir.FullName.Replace('\\', '/'), aspireMapping.Source);
}
[Fact]
public async Task GetChannelsAsync_WhenPackagesOverrideSet_ReplacesSameNamedDiscoveredHive()
{
// A discovered ~/.aspire/hives/<channel> hive must not mask the explicit override directory:
// the override is the most specific signal of intent, so it wins and only one channel remains.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives"));
var staleHiveDir = Directory.CreateDirectory(Path.Combine(hivesDir.FullName, PackageChannelNames.Daily, "packages"));
File.WriteAllText(Path.Combine(staleHiveDir.FullName, "Aspire.Hosting.13.4.0.nupkg"), string.Empty);
var packagesOverrideDir = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "shipping"));
const string overrideVersion = "13.5.0-preview.1.26310.9";
File.WriteAllText(Path.Combine(packagesOverrideDir.FullName, $"Aspire.Hosting.{overrideVersion}.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
identityChannel: PackageChannelNames.Daily,
hivesDirectory: hivesDir,
identityPackagesDirectory: packagesOverrideDir);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var channels = await packagingService.GetChannelsAsync().DefaultTimeout();
var overrideChannel = Assert.Single(channels, c => string.Equals(c.Name, PackageChannelNames.Daily, StringComparison.OrdinalIgnoreCase));
var aspireMapping = Assert.Single(overrideChannel.Mappings!, m => m.PackageFilter == "Aspire*");
Assert.Equal(packagesOverrideDir.FullName.Replace('\\', '/'), aspireMapping.Source);
Assert.Equal(overrideVersion, overrideChannel.PinnedVersion);
}
[Fact]
public async Task GetChannelsAsync_WhenPackagesOverrideHasDuplicateAspireVersions_ThrowsFailFast()
{
// A flat directory has no latest-stable/latest-prerelease semantics, so two versions of the
// same Aspire package would let NuGet silently resolve the highest. Fail fast instead.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var packagesOverrideDir = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "shipping"));
File.WriteAllText(Path.Combine(packagesOverrideDir.FullName, "Aspire.Hosting.13.4.1.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(packagesOverrideDir.FullName, "Aspire.Hosting.13.4.2.nupkg"), string.Empty);
// A single-versioned package alongside the duplicate must not be reported as a conflict.
File.WriteAllText(Path.Combine(packagesOverrideDir.FullName, "Aspire.ProjectTemplates.13.4.2.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
identityChannel: PackageChannelNames.Daily,
identityPackagesDirectory: packagesOverrideDir);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => packagingService.GetChannelsAsync()).DefaultTimeout();
Assert.Contains("Aspire.Hosting", ex.Message);
Assert.Contains("13.4.1", ex.Message);
Assert.Contains("13.4.2", ex.Message);
Assert.DoesNotContain("Aspire.ProjectTemplates", ex.Message);
}
[Fact]
public async Task GetChannelsAsync_WhenPackagesOverrideHasNestedDuplicateAspireVersions_ThrowsFailFast()
{
// Local package discovery walks the override directory recursively, so a duplicate hidden in a
// subdirectory is just as ambiguous as a flat one. The guardrail must enumerate recursively too,
// otherwise nested duplicates bypass it and NuGet silently resolves the highest version.
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var packagesOverrideDir = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "shipping"));
File.WriteAllText(Path.Combine(packagesOverrideDir.FullName, "Aspire.Hosting.13.4.1.nupkg"), string.Empty);
var nestedDir = Directory.CreateDirectory(Path.Combine(packagesOverrideDir.FullName, "nested"));
File.WriteAllText(Path.Combine(nestedDir.FullName, "Aspire.Hosting.13.4.2.nupkg"), string.Empty);
// The same version appearing in more than one directory is not ambiguous, so it must not be reported.
File.WriteAllText(Path.Combine(nestedDir.FullName, "Aspire.ProjectTemplates.13.4.2.nupkg"), string.Empty);
File.WriteAllText(Path.Combine(packagesOverrideDir.FullName, "Aspire.ProjectTemplates.13.4.2.nupkg"), string.Empty);
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
identityChannel: PackageChannelNames.Daily,
identityPackagesDirectory: packagesOverrideDir);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => packagingService.GetChannelsAsync()).DefaultTimeout();
Assert.Contains("Aspire.Hosting", ex.Message);
Assert.Contains("13.4.1", ex.Message);
Assert.Contains("13.4.2", ex.Message);
Assert.DoesNotContain("Aspire.ProjectTemplates", ex.Message);
}
[Fact]
public async Task GetChannelsAsync_WhenPackagesOverrideDirectoryMissing_ThrowsFailFast()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var tempDir = workspace.WorkspaceRoot;
var missingDir = new DirectoryInfo(Path.Combine(tempDir.FullName, "does-not-exist"));
var executionContext = TestExecutionContextHelper.CreateExecutionContext(
tempDir,
identityChannel: PackageChannelNames.Daily,
identityPackagesDirectory: missingDir);
var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), new ConfigurationBuilder().Build(), NullLogger<PackagingService>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => packagingService.GetChannelsAsync()).DefaultTimeout();
Assert.Contains(missingDir.FullName, ex.Message);
}
[Theory]
[InlineData("Aspire.Hosting.Azure.Storage.13.4.0-preview.1.25366.3.nupkg", "Aspire.Hosting.Azure.Storage", "13.4.0-preview.1.25366.3")]
[InlineData("Aspire.Cli.13.4.0.nupkg", "Aspire.Cli", "13.4.0")]
[InlineData("Aspire.Hosting.13.5.0-dev.nupkg", "Aspire.Hosting", "13.5.0-dev")]
[InlineData("Aspire.13.4.3.nupkg", "Aspire", "13.4.3")]
[InlineData("Some.Package.1.0.0.nupkg", "Some.Package", "1.0.0")]
// Case-insensitive ".nupkg" extension is honored.
[InlineData("Aspire.Hosting.13.4.0.NUPKG", "Aspire.Hosting", "13.4.0")]
public void TryParseNupkgFileName_ParsesIdAndVersion(string fileName, string expectedId, string expectedVersion)
{
Assert.True(PackagingService.TryParseNupkgFileName(fileName, out var id, out var version));
Assert.Equal(expectedId, id);
Assert.Equal(expectedVersion, version);
}
[Theory]
[InlineData("")]
[InlineData("Aspire.Hosting.nupkg")] // no version component
[InlineData("Aspire.Hosting.13.4.0")] // missing .nupkg extension
[InlineData("Aspire.Hosting.13.4.0.zip")] // wrong extension
[InlineData("Aspire.Hosting.not.a.version.nupkg")] // no segment starts with a digit
[InlineData("13.4.0.nupkg")] // version with no preceding id segment
public void TryParseNupkgFileName_ReturnsFalse_ForInvalidNames(string fileName)
{
Assert.False(PackagingService.TryParseNupkgFileName(fileName, out var id, out var version));
Assert.Equal(string.Empty, id);
Assert.Equal(string.Empty, version);
}
private sealed class FakeNuGetPackageCacheWithPackages(List<Aspire.Shared.NuGetPackageCli> packages) : INuGetPackageCache
{
public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
{
// Simulate what the real cache does: filter by prerelease flag
var filtered = prerelease
? packages.Where(p => Semver.SemVersion.Parse(p.Version).IsPrerelease)
: packages.Where(p => !Semver.SemVersion.Parse(p.Version).IsPrerelease);
return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>(filtered.ToList());
}
public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
=> GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken);
public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
=> Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>([]);
public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func<string, bool>? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken)
=> GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken);
public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetPackageVersionsAsync(DirectoryInfo workingDirectory, string exactPackageId, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken)
=> GetTemplatePackagesAsync(workingDirectory, prerelease, nugetConfigFile, cancellationToken);
}
}