File: Packaging\PackageChannelTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Compression;
using Aspire.Cli.Configuration;
using Aspire.Cli.Packaging;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging.Abstractions;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
 
namespace Aspire.Cli.Tests.Packaging;
 
public class PackageChannelTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void SourceDetails_ImplicitChannel_ReturnsBasedOnNuGetConfig()
    {
        // Arrange
        var cache = new FakeNuGetPackageCache();
 
        // Act
        var channel = PackageChannel.CreateImplicitChannel(cache, new TestFeatures(), NullLogger.Instance);
 
        // Assert
        Assert.Equal(PackagingStrings.BasedOnNuGetConfig, channel.SourceDetails);
        Assert.Equal(PackageChannelType.Implicit, channel.Type);
    }
 
    [Fact]
    public void SourceDetails_ExplicitChannelWithAspireMapping_ReturnsSourceFromMapping()
    {
        // Arrange
        var cache = new FakeNuGetPackageCache();
        var aspireSource = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json";
        var mappings = new[]
        {
            new PackageMapping("Aspire*", aspireSource),
            new PackageMapping("*", "https://api.nuget.org/v3/index.json")
        };
 
        // Act
        var channel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        // Assert
        Assert.Equal(aspireSource, channel.SourceDetails);
        Assert.Equal(PackageChannelType.Explicit, channel.Type);
    }
 
    [Fact]
    public void SourceDetails_ExplicitChannelWithPrHivePath_ReturnsLocalPath()
    {
        // Arrange
        var cache = new FakeNuGetPackageCache();
        var prHivePath = "/Users/davidfowler/.aspire/hives/pr-10981";
        var mappings = new[]
        {
            new PackageMapping("Aspire*", prHivePath),
            new PackageMapping("*", "https://api.nuget.org/v3/index.json")
        };
 
        // Act
        var channel = PackageChannel.CreateExplicitChannel("pr-10981", PackageChannelQuality.Prerelease, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        // Assert
        Assert.Equal(prHivePath, channel.SourceDetails);
        Assert.Equal(PackageChannelType.Explicit, channel.Type);
    }
 
    [Fact]
    public void SourceDetails_ExplicitChannelWithStagingUrl_ReturnsStagingUrl()
    {
        // Arrange
        var cache = new FakeNuGetPackageCache();
        var stagingUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-48a11dae/nuget/v3/index.json";
        var mappings = new[]
        {
            new PackageMapping("Aspire*", stagingUrl),
            new PackageMapping("*", "https://api.nuget.org/v3/index.json")
        };
 
        // Act
        var channel = PackageChannel.CreateExplicitChannel("staging", PackageChannelQuality.Stable, mappings, cache, new TestFeatures(), NullLogger.Instance, configureGlobalPackagesFolder: true);
 
        // Assert
        Assert.Equal(stagingUrl, channel.SourceDetails);
        Assert.Equal(PackageChannelType.Explicit, channel.Type);
        Assert.True(channel.ConfigureGlobalPackagesFolder);
    }
 
    [Fact]
    public void SourceDetails_EmptyMappingsArray_ReturnsBasedOnNuGetConfig()
    {
        // Arrange
        var cache = new FakeNuGetPackageCache();
        var mappings = Array.Empty<PackageMapping>();
 
        // Act
        var channel = PackageChannel.CreateExplicitChannel("empty", PackageChannelQuality.Stable, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        // Assert
        Assert.Equal(PackagingStrings.BasedOnNuGetConfig, channel.SourceDetails);
        Assert.Equal(PackageChannelType.Explicit, channel.Type);
    }
 
    [Fact]
    public async Task GetTemplatePackagesAsync_PinnedChannelWithMappingsOverride_UsesOverrideSource()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        const string pinnedVersion = "13.5.0-preview.1";
        const string channelSource = "https://channel.example/v3/index.json";
        const string sourceOverride = "https://proxy.example/v3/index.json";
        var channel = PackageChannel.CreateExplicitChannel(
            "staging",
            PackageChannelQuality.Prerelease,
            [
                new PackageMapping("Aspire*", channelSource),
                new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
            ],
            new FakeNuGetPackageCache(),
            new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: pinnedVersion);
 
        var package = Assert.Single(await channel.GetTemplatePackagesAsync(
            workspace.WorkspaceRoot,
            PackageSourceOverrideMappings.CreateForTemplateOperations(sourceOverride),
            CancellationToken.None));
 
        Assert.Equal(pinnedVersion, package.Version);
        Assert.Equal(sourceOverride, package.Source);
        Assert.Equal(channelSource, channel.SourceDetails);
    }
 
    [Fact]
    public async Task GetTemplatePackagesAsync_PinnedChannelWithLocalMappingsOverride_DoesNotFilterToPinnedVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        var nestedDirectory = Directory.CreateDirectory(Path.Combine(
            packagesDirectory.FullName,
            "aspire.projecttemplates",
            "13.6.0"));
 
        File.WriteAllText(
            Path.Combine(nestedDirectory.FullName, "Aspire.ProjectTemplates.13.6.0.nupkg"),
            string.Empty);
 
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
        var channel = PackageChannel.CreateExplicitChannel(
            "local",
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", packageSource)],
            new FakeNuGetPackageCache(),
            new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: "13.7.0");
 
        var package = Assert.Single(await channel.GetTemplatePackagesAsync(
            workspace.WorkspaceRoot,
            PackageSourceOverrideMappings.CreateForTemplateOperations(packageSource),
            CancellationToken.None).DefaultTimeout());
 
        Assert.Equal("13.6.0", package.Version);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task GetTemplatePackagesAsync_PinnedLocalChannel_RequiresMatchingPackageFile(bool packageFileExists)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        const string pinnedVersion = "13.6.0-dev";
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
 
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"), string.Empty);
 
        if (packageFileExists)
        {
            File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{pinnedVersion}.nupkg"), string.Empty);
        }
 
        var cache = new FakeNuGetPackageCache
        {
            GetTemplatePackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
        };
        var channel = PackageChannel.CreateExplicitChannel(
            "local",
            PackageChannelQuality.Both,
            [
                new PackageMapping("Aspire*", packageSource),
                new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
            ],
            cache,
            new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: pinnedVersion);
 
        var packages = (await channel.GetTemplatePackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        if (!packageFileExists)
        {
            Assert.Empty(packages);
            return;
        }
 
        var package = Assert.Single(packages);
        Assert.Equal("Aspire.ProjectTemplates", package.Id);
        Assert.Equal(pinnedVersion, package.Version);
        Assert.Equal(packageSource, package.Source);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task GetTemplatePackagesAsync_PinnedRemoteChannelWithLocalMappingsOverride_RequiresMatchingPackageFile(bool packageFileExists)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        const string pinnedVersion = "13.6.0-dev";
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
 
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.ProjectTemplates.13.5.0.nupkg"), string.Empty);
        if (packageFileExists)
        {
            File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{pinnedVersion}.nupkg"), string.Empty);
        }
 
        var channel = PackageChannel.CreateExplicitChannel(
            "staging",
            PackageChannelQuality.Prerelease,
            [new PackageMapping("Aspire*", "https://example.invalid/staging/v3/index.json")],
            new FakeNuGetPackageCache(),
            new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: pinnedVersion);
 
        var packages = (await channel.GetTemplatePackagesAsync(
            workspace.WorkspaceRoot,
            PackageSourceOverrideMappings.CreateForTemplateOperations(packageSource),
            CancellationToken.None).DefaultTimeout()).ToArray();
 
        if (!packageFileExists)
        {
            Assert.Empty(packages);
            return;
        }
 
        var package = Assert.Single(packages);
        Assert.Equal(pinnedVersion, package.Version);
        Assert.Equal(packageSource, package.Source);
    }
 
    [Fact]
    public async Task GetTemplatePackagesAsync_UnpinnedChannelWithLocalMappingsOverride_EnumeratesOverrideDirectory()
    {
        // `aspire new --source <dir>` hands per-call mappings to an unpinned explicit channel. Local
        // directory discovery must follow those mappings, not the channel's own Aspire* mapping,
        // otherwise the override is silently ignored and the channel's directory is listed instead.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var channelPackagesDirectory = workspace.CreateDirectory("channel-packages");
        var overridePackagesDirectory = workspace.CreateDirectory("override-packages");
 
        File.WriteAllText(Path.Combine(channelPackagesDirectory.FullName, "Aspire.ProjectTemplates.13.4.0-preview.1.nupkg"), string.Empty);
 
        // Nested layout mirrors a hierarchical local feed: <id lowercased>/<version>/<id>.<version>.nupkg
        var nestedDirectory = Directory.CreateDirectory(Path.Combine(overridePackagesDirectory.FullName, "aspire.projecttemplates", "13.5.0-preview.2"));
        File.WriteAllText(Path.Combine(nestedDirectory.FullName, "Aspire.ProjectTemplates.13.5.0-preview.2.nupkg"), string.Empty);
 
        var cache = new FakeNuGetPackageCache
        {
            GetTemplatePackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
        };
        var channelSource = channelPackagesDirectory.FullName.Replace('\\', '/');
        var overrideSource = overridePackagesDirectory.FullName.Replace('\\', '/');
        var channel = PackageChannel.CreateExplicitChannel(
            "daily",
            PackageChannelQuality.Prerelease,
            [
                new PackageMapping("Aspire*", channelSource),
                new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)
            ],
            cache,
            new TestFeatures(),
            NullLogger.Instance);
 
        var package = Assert.Single(await channel.GetTemplatePackagesAsync(
            workspace.WorkspaceRoot,
            PackageSourceOverrideMappings.CreateForTemplateOperations(overrideSource),
            CancellationToken.None).DefaultTimeout());
 
        Assert.Equal("Aspire.ProjectTemplates", package.Id);
        Assert.Equal("13.5.0-preview.2", package.Version);
        Assert.Equal(overrideSource, package.Source);
    }
 
    [Fact]
    public async Task GetTemplatePackagesAsync_ImplicitChannelWithLocalMappingsOverride_EnumeratesOverrideDirectory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        var nestedDirectory = Directory.CreateDirectory(Path.Combine(packagesDirectory.FullName, "aspire.projecttemplates", "13.5.0-preview.2"));
        File.WriteAllText(Path.Combine(nestedDirectory.FullName, "Aspire.ProjectTemplates.13.5.0-preview.2.nupkg"), string.Empty);
 
        var cache = new FakeNuGetPackageCache
        {
            GetTemplatePackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
        };
        var channel = PackageChannel.CreateImplicitChannel(cache, new TestFeatures(), NullLogger.Instance);
        var source = packagesDirectory.FullName.Replace('\\', '/');
 
        var package = Assert.Single(await channel.GetTemplatePackagesAsync(
            workspace.WorkspaceRoot,
            PackageSourceOverrideMappings.CreateForTemplateOperations(source),
            CancellationToken.None).DefaultTimeout());
 
        Assert.Equal("Aspire.ProjectTemplates", package.Id);
        Assert.Equal("13.5.0-preview.2", package.Version);
        Assert.Equal(source, package.Source);
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_WithPinnedLocalSource_ReturnsOnlyPinnedLocalIntegrationPackages()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        const string pinnedVersion = "13.4.0-pr.16820.gabcdef";
 
        // Kept — Aspire.Hosting.* / CommunityToolkit.Aspire.Hosting.* integration namespaces.
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Redis.{pinnedVersion}.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.PostgreSQL.{pinnedVersion}.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"CommunityToolkit.Aspire.Hosting.NodeJS.{pinnedVersion}.nupkg"), string.Empty);
 
        // Dropped — pinned-version mismatch (otherwise-eligible integration at the wrong version).
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.SqlServer.13.3.0.nupkg"), string.Empty);
 
        // Dropped — outside the integration namespace.
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.ProjectTemplates.{pinnedVersion}.nupkg"), string.Empty);
 
        // Dropped — internal Aspire framework packages (AppHost, Sdk, Orchestration.*, Testing, Msi, Integration.Analyzers).
        // Orchestration is seeded with a RID-suffixed shape because no bare
        // Aspire.Hosting.Orchestration nupkg is produced by the build; the exclusion is a
        // prefix rule, so one RID variant exercises the rule against a realistic package name
        // (a regression that tightened StartsWith to Equals would leak every .<rid> variant).
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.AppHost.{pinnedVersion}.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Sdk.{pinnedVersion}.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Orchestration.linux-arm64.{pinnedVersion}.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Testing.{pinnedVersion}.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Msi.{pinnedVersion}.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Integration.Analyzers.{pinnedVersion}.nupkg"), string.Empty);
 
        // Dropped — deprecated packages enumerated in DeprecatedPackages.
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.Dapr.{pinnedVersion}.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.GitHub.Models.{pinnedVersion}.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, $"Aspire.Hosting.NodeJs.{pinnedVersion}.nupkg"), string.Empty);
 
        var cache = new FakeNuGetPackageCache
        {
            GetIntegrationPackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
        };
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
        var mappings = new[]
        {
            new PackageMapping("Aspire*", packageSource),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
        var channel = PackageChannel.CreateExplicitChannel("local", PackageChannelQuality.Both, mappings, cache, new TestFeatures(), NullLogger.Instance, pinnedVersion: pinnedVersion);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        Assert.Collection(
            packages,
            package =>
            {
                Assert.Equal("Aspire.Hosting.PostgreSQL", package.Id);
                Assert.Equal(pinnedVersion, package.Version);
                Assert.Equal(packageSource, package.Source);
            },
            package =>
            {
                Assert.Equal("Aspire.Hosting.Redis", package.Id);
                Assert.Equal(pinnedVersion, package.Version);
                Assert.Equal(packageSource, package.Source);
            },
            package =>
            {
                Assert.Equal("CommunityToolkit.Aspire.Hosting.NodeJS", package.Id);
                Assert.Equal(pinnedVersion, package.Version);
                Assert.Equal(packageSource, package.Source);
            });
    }
 
    [Fact]
    public async Task GetPolyglotCompatiblePackageIdsAsync_WithPinnedLocalSource_ReturnsOnlyTaggedIntegrationPackageIds()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        const string pinnedVersion = "13.4.0-pr.16820.gabcdef";
 
        CreatePackageWithTags(packagesDirectory, "Aspire.Hosting.Redis", pinnedVersion, "aspire integration hosting cache polyglot");
        CreatePackageWithTags(packagesDirectory, "Aspire.Hosting.PostgreSQL", pinnedVersion, "aspire integration hosting database");
        CreatePackageWithTags(packagesDirectory, "Aspire.Hosting.MongoDB", pinnedVersion, "aspire integration hosting notpolyglot polyglotted");
        CreatePackageWithTags(packagesDirectory, "Aspire.ProjectTemplates", pinnedVersion, "aspire templates polyglot");
 
        var cache = new FakeNuGetPackageCache
        {
            GetPackagesAsyncCallback = (_, _, _, _, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
        };
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
        var mappings = new[]
        {
            new PackageMapping("Aspire*", packageSource),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
        var channel = PackageChannel.CreateExplicitChannel("local", PackageChannelQuality.Both, mappings, cache, new TestFeatures(), NullLogger.Instance, pinnedVersion: pinnedVersion);
 
        var packageIds = (await channel.GetPolyglotCompatiblePackageIdsAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout())
            .OrderBy(id => id, StringComparer.Ordinal)
            .ToArray();
 
        Assert.Equal(["Aspire.Hosting.Redis"], packageIds);
    }
 
    [Fact]
    public async Task GetPolyglotCompatiblePackageIdsAsync_LocalFolderSource_UsesConfiguredSdkVersion()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        CreatePackageWithTags(packagesDirectory, "Aspire.Hosting.Redis", "13.6.0-dev", "aspire integration hosting cache");
        CreatePackageWithTags(packagesDirectory, "Aspire.Hosting.Redis", "99.0.0", "aspire integration hosting cache polyglot");
 
        var config = AspireConfigFile.LoadOrCreate(workspace.WorkspaceRoot.FullName);
        config.Channel = PackageChannelNames.Local;
        config.SdkVersion = "13.6.0-dev";
        config.Save(workspace.WorkspaceRoot.FullName);
 
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
        var channel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Local,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", packageSource)],
            new FakeNuGetPackageCache(),
            new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: "99.0.0",
            currentCliVersion: "13.6.0-dev");
 
        var packageIds = await channel.GetPolyglotCompatiblePackageIdsAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
 
        Assert.Empty(packageIds);
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_AndGetPolyglotCompatiblePackageIdsAsync_WithHierarchicalLocalSource_FindsNestedTaggedPackage()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        const string version = "13.4.0-pr.16820.gabcdef";
 
        CreateHierarchicalPackageWithTags(packagesDirectory, "Aspire.Hosting.Redis", version, "aspire integration hosting cache polyglot");
 
        var cache = new FakeNuGetPackageCache
        {
            GetIntegrationPackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly."),
            GetPackagesAsyncCallback = (_, _, _, _, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
        };
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
        var mappings = new[]
        {
            new PackageMapping("Aspire*", packageSource),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
        var channel = PackageChannel.CreateExplicitChannel("local", PackageChannelQuality.Both, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
        var packageIds = await channel.GetPolyglotCompatiblePackageIdsAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout();
 
        var package = Assert.Single(packages);
        Assert.Equal("Aspire.Hosting.Redis", package.Id);
        Assert.Equal(version, package.Version);
        Assert.Equal(packageSource, package.Source);
        Assert.Equal(["Aspire.Hosting.Redis"], packageIds.OrderBy(id => id, StringComparer.Ordinal).ToArray());
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_WithStableRemoteSource_QueriesOnlyStablePackages()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var stableSearches = 0;
        var prereleaseSearches = 0;
        var cache = new FakeNuGetPackageCache
        {
            GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) =>
            {
                if (prerelease)
                {
                    Interlocked.Increment(ref prereleaseSearches);
                    return Task.FromResult<IEnumerable<NuGetPackage>>(
                    [
                        new() { Id = "Aspire.Hosting.Azure.Kubernetes", Version = "13.5.0-preview.1" },
                        new() { Id = "Aspire.Hosting.Redis", Version = "13.6.0-preview.1" }
                    ]);
                }
 
                Interlocked.Increment(ref stableSearches);
                return Task.FromResult<IEnumerable<NuGetPackage>>(
                [
                    new() { Id = "Aspire.Hosting.Redis", Version = "13.5.0" }
                ]);
            }
        };
        var channel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Stable,
            PackageChannelQuality.Stable,
            [new PackageMapping(PackageMapping.AllPackages, PackageSources.NuGetOrg)],
            cache,
            new TestFeatures(),
            NullLogger.Instance);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        Assert.Equal(1, stableSearches);
        Assert.Equal(0, prereleaseSearches);
        var package = Assert.Single(packages);
        Assert.Equal("Aspire.Hosting.Redis", package.Id);
        Assert.Equal("13.5.0", package.Version);
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_WithPrereleaseRemoteSource_QueriesOnlyPrereleaseAndFiltersStableNoise()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var stableSearches = 0;
        var prereleaseSearches = 0;
        var cache = new FakeNuGetPackageCache
        {
            GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) =>
            {
                if (!prerelease)
                {
                    Interlocked.Increment(ref stableSearches);
                    return Task.FromResult<IEnumerable<NuGetPackage>>([]);
                }
 
                Interlocked.Increment(ref prereleaseSearches);
                return Task.FromResult<IEnumerable<NuGetPackage>>(
                [
                    new() { Id = "Aspire.Hosting.Redis", Version = "13.5.0" },
                    new() { Id = "Aspire.Hosting.Redis", Version = "13.6.0-preview.1" }
                ]);
            }
        };
        var channel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Daily,
            PackageChannelQuality.Prerelease,
            [new PackageMapping("Aspire*", "https://daily.example/v3/index.json")],
            cache,
            new TestFeatures(),
            NullLogger.Instance);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        Assert.Equal(0, stableSearches);
        Assert.Equal(1, prereleaseSearches);
        var package = Assert.Single(packages);
        Assert.Equal("Aspire.Hosting.Redis", package.Id);
        Assert.Equal("13.6.0-preview.1", package.Version);
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_WithBothRemoteSource_ReturnsStableAndPrereleasePackages()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var stableSearches = 0;
        var prereleaseSearches = 0;
        var cache = new FakeNuGetPackageCache
        {
            GetIntegrationPackagesAsyncCallback = (_, prerelease, _, _) =>
            {
                if (prerelease)
                {
                    Interlocked.Increment(ref prereleaseSearches);
                    return Task.FromResult<IEnumerable<NuGetPackage>>(
                    [
                        new() { Id = "Aspire.Hosting.Azure.Kubernetes", Version = "13.6.0-pr.19404.gf51e8e1d" },
                        new() { Id = "Aspire.Hosting.Redis", Version = "13.5.0" },
                        new() { Id = "Aspire.Hosting.Redis", Version = "13.6.0-preview.1" }
                    ]);
                }
 
                Interlocked.Increment(ref stableSearches);
                return Task.FromResult<IEnumerable<NuGetPackage>>(
                [
                    new() { Id = "Aspire.Hosting.Redis", Version = "13.5.0" }
                ]);
            }
        };
        var channel = PackageChannel.CreateExplicitChannel(
            "pr-19404",
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", "https://pr.example/v3/index.json")],
            cache,
            new TestFeatures(),
            NullLogger.Instance);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout())
            .OrderBy(package => package.Id, StringComparer.Ordinal)
            .ThenBy(package => package.Version, StringComparer.Ordinal)
            .ToArray();
 
        Assert.Equal(1, stableSearches);
        Assert.Equal(1, prereleaseSearches);
        Assert.Collection(
            packages,
            package =>
            {
                Assert.Equal("Aspire.Hosting.Azure.Kubernetes", package.Id);
                Assert.Equal("13.6.0-pr.19404.gf51e8e1d", package.Version);
            },
            package =>
            {
                Assert.Equal("Aspire.Hosting.Redis", package.Id);
                Assert.Equal("13.5.0", package.Version);
            },
            package =>
            {
                Assert.Equal("Aspire.Hosting.Redis", package.Id);
                Assert.Equal("13.6.0-preview.1", package.Version);
            });
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_WithStableLocalSource_ReturnsOnlyStablePackages()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
 
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.4.0.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.5.0-preview.1.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.PostgreSQL.13.4.0-preview.1.nupkg"), string.Empty);
 
        var channel = CreateLocalChannel(packagesDirectory, PackageChannelQuality.Stable);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        var package = Assert.Single(packages);
        Assert.Equal("Aspire.Hosting.Redis", package.Id);
        Assert.Equal("13.4.0", package.Version);
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_WithPrereleaseLocalSource_ReturnsOnlyPrereleasePackages()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
 
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.4.0.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.5.0-preview.1.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.PostgreSQL.13.4.0.nupkg"), string.Empty);
 
        var channel = CreateLocalChannel(packagesDirectory, PackageChannelQuality.Prerelease);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        var package = Assert.Single(packages);
        Assert.Equal("Aspire.Hosting.Redis", package.Id);
        Assert.Equal("13.5.0-preview.1", package.Version);
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_WithBothLocalSource_ReturnsLatestPackageAcrossQualities()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
 
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.5.0.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Redis.13.6.0-preview.1.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Azure.Kubernetes.13.6.0-pr.19404.gf51e8e1d.nupkg"), string.Empty);
 
        var channel = CreateLocalChannel(packagesDirectory, PackageChannelQuality.Both);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        Assert.Collection(
            packages,
            package =>
            {
                Assert.Equal("Aspire.Hosting.Azure.Kubernetes", package.Id);
                Assert.Equal("13.6.0-pr.19404.gf51e8e1d", package.Version);
            },
            package =>
            {
                Assert.Equal("Aspire.Hosting.Redis", package.Id);
                Assert.Equal("13.6.0-preview.1", package.Version);
            });
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_LocalFolderSource_FiltersDeprecatedByDefault()
    {
        // Mirrors the feed-based behavior in NuGetPackageCache: when the
        // ShowDeprecatedPackages feature flag is off (the default), deprecated
        // integration package ids must be hidden from local-hive / PR-hive listings.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
 
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Dapr.13.4.0.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Sql.13.4.0.nupkg"), string.Empty);
 
        var channel = CreateLocalChannel(packagesDirectory, PackageChannelQuality.Stable);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        Assert.DoesNotContain(packages, p => string.Equals(p.Id, "Aspire.Hosting.Dapr", StringComparison.OrdinalIgnoreCase));
        Assert.Contains(packages, p => string.Equals(p.Id, "Aspire.Hosting.Sql", StringComparison.OrdinalIgnoreCase));
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_LocalFolderSource_IncludesDeprecatedWhenFlagEnabled()
    {
        // When ShowDeprecatedPackages is enabled, deprecated ids must appear in
        // local-hive listings just as they do on the feed-based path; without this,
        // a user who flipped the flag silently sees nothing change on PR/local hives.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
 
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Dapr.13.4.0.nupkg"), string.Empty);
        File.WriteAllText(Path.Combine(packagesDirectory.FullName, "Aspire.Hosting.Sql.13.4.0.nupkg"), string.Empty);
 
        var features = new TestFeatures().SetFeature(KnownFeatures.ShowDeprecatedPackages, true);
        var channel = CreateLocalChannel(packagesDirectory, PackageChannelQuality.Stable, features);
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        Assert.Contains(packages, p => string.Equals(p.Id, "Aspire.Hosting.Dapr", StringComparison.OrdinalIgnoreCase));
        Assert.Contains(packages, p => string.Equals(p.Id, "Aspire.Hosting.Sql", StringComparison.OrdinalIgnoreCase));
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_LocalFolderSource_UsesConfiguredSdkVersionInsteadOfStaleChannelPin()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        CreatePackageWithTags(packagesDirectory, "Aspire.Hosting.Redis", "13.6.0-dev", string.Empty);
        CreatePackageWithTags(packagesDirectory, "Aspire.Hosting.Redis", "99.0.0", string.Empty);
 
        var config = AspireConfigFile.LoadOrCreate(workspace.WorkspaceRoot.FullName);
        config.Channel = PackageChannelNames.Local;
        config.SdkVersion = "13.6.0-dev";
        config.Save(workspace.WorkspaceRoot.FullName);
 
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
        var channel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Local,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", packageSource)],
            new FakeNuGetPackageCache(),
            new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: "99.0.0",
            currentCliVersion: "13.6.0-dev");
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        var package = Assert.Single(packages);
        Assert.Equal("Aspire.Hosting.Redis", package.Id);
        Assert.Equal("13.6.0-dev", package.Version);
    }
 
    [Fact]
    public async Task GetIntegrationPackagesAsync_LocalFolderSource_BlankConfiguredSdkVersionUsesChannelPin()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        CreatePackageWithTags(packagesDirectory, "Aspire.Hosting.Redis", "13.6.0-dev", string.Empty);
        CreatePackageWithTags(packagesDirectory, "Aspire.Hosting.Redis", "99.0.0", string.Empty);
 
        var config = AspireConfigFile.LoadOrCreate(workspace.WorkspaceRoot.FullName);
        config.Channel = PackageChannelNames.Local;
        config.SdkVersion = " ";
        config.Save(workspace.WorkspaceRoot.FullName);
 
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
        var channel = PackageChannel.CreateExplicitChannel(
            PackageChannelNames.Local,
            PackageChannelQuality.Both,
            [new PackageMapping("Aspire*", packageSource)],
            new FakeNuGetPackageCache(),
            new TestFeatures(),
            NullLogger.Instance,
            pinnedVersion: "99.0.0",
            currentCliVersion: "13.6.0-dev");
 
        var packages = (await channel.GetIntegrationPackagesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout()).ToArray();
 
        var package = Assert.Single(packages);
        Assert.Equal("Aspire.Hosting.Redis", package.Id);
        Assert.Equal("99.0.0", package.Version);
    }
 
    [Fact]
    public void ShouldCreateNuGetConfig_StableChannel_ReturnsFalse()
    {
        // A real stable channel still carries mappings (everything -> nuget.org), so the
        // exclusion must come from the channel name, not from an absence of mappings. This
        // guards against scaffolding dropping a redundant <clear/>-based NuGet.config that
        // would wipe the user's ambient feeds.
        var cache = new FakeNuGetPackageCache();
        var mappings = new[]
        {
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        var channel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.False(channel.ShouldPersistChannelName());
        Assert.False(channel.ShouldCreateNuGetConfig());
    }
 
    [Fact]
    public void ShouldCreateNuGetConfig_DailyChannel_ReturnsTrue()
    {
        var cache = new FakeNuGetPackageCache();
        var mappings = new[]
        {
            new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        var channel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.True(channel.ShouldPersistChannelName());
        Assert.True(channel.ShouldCreateNuGetConfig());
    }
 
    [Fact]
    public void ShouldCreateNuGetConfig_StagingChannel_ReturnsTrue()
    {
        var cache = new FakeNuGetPackageCache();
        var mappings = new[]
        {
            new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-microsoft-aspire-abc1234/nuget/v3/index.json"),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        var channel = PackageChannel.CreateExplicitChannel("staging", PackageChannelQuality.Stable, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.True(channel.ShouldPersistChannelName());
        Assert.True(channel.ShouldCreateNuGetConfig());
    }
 
    [Fact]
    public void ShouldCreateNuGetConfig_PrChannel_ReturnsTrue()
    {
        var cache = new FakeNuGetPackageCache();
        var mappings = new[]
        {
            new PackageMapping("Aspire*", "/tmp/pr-hive/12345"),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        var channel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Prerelease, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.True(channel.ShouldPersistChannelName());
        Assert.True(channel.ShouldCreateNuGetConfig());
    }
 
    [Fact]
    public void ShouldCreateNuGetConfig_ImplicitChannel_ReturnsFalse()
    {
        var cache = new FakeNuGetPackageCache();
 
        var channel = PackageChannel.CreateImplicitChannel(cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.False(channel.ShouldPersistChannelName());
        Assert.False(channel.ShouldCreateNuGetConfig());
    }
 
    [Fact]
    public void ShouldCreateNuGetConfig_ExplicitChannelWithoutMappings_ReturnsFalse()
    {
        // An Explicit channel constructed with an empty mappings array has no custom feed to
        // pin, so there is nothing to write into a NuGet.config even though the name would
        // otherwise be persisted.
        var cache = new FakeNuGetPackageCache();
 
        var channel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, [], cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.True(channel.ShouldPersistChannelName());
        Assert.False(channel.ShouldCreateNuGetConfig());
    }
 
    [Fact]
    public void IsBackedByLocalPackageDirectory_StableNamedChannelMappedToLocalDirectory_ReturnsTrue()
    {
        // This is the ASPIRE_CLI_PACKAGES emulation shape: a locally built CLI emulating a
        // released build synthesizes a channel NAMED after the emulated identity (here "stable")
        // whose Aspire* mapping points at a local directory of .nupkg files. The name-based
        // VersionHelper.IsLocalBuildChannel("stable") would call this remote, so resolution must
        // instead recognize the local directory from the mapping.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        var cache = new FakeNuGetPackageCache();
        var mappings = new[]
        {
            new PackageMapping("Aspire*", packagesDirectory.FullName.Replace('\\', '/')),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        var channel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Both, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.True(channel.IsBackedByLocalPackageDirectory);
    }
 
    [Fact]
    public void IsBackedByLocalPackageDirectory_FileUriAspireMapping_ReturnsTrue()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var packagesDirectory = workspace.CreateDirectory("packages");
        var cache = new FakeNuGetPackageCache();
        var mappings = new[]
        {
            new PackageMapping("Aspire*", new Uri(packagesDirectory.FullName).AbsoluteUri),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        var channel = PackageChannel.CreateExplicitChannel("pr-12345", PackageChannelQuality.Both, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.True(channel.IsBackedByLocalPackageDirectory);
    }
 
    [Fact]
    public void IsBackedByLocalPackageDirectory_RemoteStableChannel_ReturnsFalse()
    {
        // A real stable channel maps everything to nuget.org, so it is not locally backed.
        var cache = new FakeNuGetPackageCache();
        var mappings = new[]
        {
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        var channel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Stable, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.False(channel.IsBackedByLocalPackageDirectory);
    }
 
    [Fact]
    public void IsBackedByLocalPackageDirectory_RemoteDailyFeed_ReturnsFalse()
    {
        // Daily routes Aspire* to an http(s) feed; an http source is never a local directory.
        var cache = new FakeNuGetPackageCache();
        var mappings = new[]
        {
            new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json"),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        var channel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.False(channel.IsBackedByLocalPackageDirectory);
    }
 
    [Fact]
    public void IsBackedByLocalPackageDirectory_AspireMappingToNonExistentDirectory_ReturnsFalse()
    {
        // Guard against a stale override: if the mapped directory no longer exists, do not claim
        // the channel can resolve Aspire packages locally (callers would otherwise skip the feed
        // fallback and fail to find any packages).
        var cache = new FakeNuGetPackageCache();
        var missingDirectory = Path.Combine(Path.GetTempPath(), $"aspire-missing-{Guid.NewGuid():N}");
        var mappings = new[]
        {
            new PackageMapping("Aspire*", missingDirectory.Replace('\\', '/')),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        var channel = PackageChannel.CreateExplicitChannel(PackageChannelNames.Stable, PackageChannelQuality.Both, mappings, cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.False(channel.IsBackedByLocalPackageDirectory);
    }
 
    [Fact]
    public void IsBackedByLocalPackageDirectory_ImplicitChannel_ReturnsFalse()
    {
        var cache = new FakeNuGetPackageCache();
 
        var channel = PackageChannel.CreateImplicitChannel(cache, new TestFeatures(), NullLogger.Instance);
 
        Assert.False(channel.IsBackedByLocalPackageDirectory);
    }
 
    private static PackageChannel CreateLocalChannel(DirectoryInfo packagesDirectory, PackageChannelQuality quality, IFeatures? features = null)
    {
        var cache = new FakeNuGetPackageCache
        {
            GetIntegrationPackagesAsyncCallback = (_, _, _, _) => throw new InvalidOperationException("Local package sources should be enumerated directly.")
        };
        var packageSource = packagesDirectory.FullName.Replace('\\', '/');
        var mappings = new[]
        {
            new PackageMapping("Aspire*", packageSource),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        };
 
        return PackageChannel.CreateExplicitChannel("local", quality, mappings, cache, features ?? new TestFeatures(), NullLogger.Instance);
    }
 
    private static void CreatePackageWithTags(DirectoryInfo packagesDirectory, string packageId, string version, string tags)
    {
        CreatePackageWithTags(Path.Combine(packagesDirectory.FullName, $"{packageId}.{version}.nupkg"), packageId, version, tags);
    }
 
    private static void CreateHierarchicalPackageWithTags(DirectoryInfo packagesDirectory, string packageId, string version, string tags)
    {
        var packagePath = Path.Combine(packagesDirectory.FullName, packageId.ToLowerInvariant(), version, $"{packageId}.{version}.nupkg");
        Directory.CreateDirectory(Path.GetDirectoryName(packagePath)!);
        CreatePackageWithTags(packagePath, packageId, version, tags);
    }
 
    private static void CreatePackageWithTags(string packagePath, string packageId, string version, string tags)
    {
        using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
        var entry = archive.CreateEntry($"{packageId}.nuspec");
        using var writer = new StreamWriter(entry.Open());
        writer.Write($"""
            <?xml version="1.0" encoding="utf-8"?>
            <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
              <metadata>
                <id>{packageId}</id>
                <version>{version}</version>
                <authors>Aspire</authors>
                <description>Test package</description>
                <tags>{tags}</tags>
              </metadata>
            </package>
            """);
    }
}