File: Projects\AppHostServerProjectTests.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.Xml.Linq;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Projects;
 
public class AppHostServerProjectTests(ITestOutputHelper outputHelper) : IDisposable
{
    private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(outputHelper);
 
    public void Dispose()
    {
        _workspace.Dispose();
        GC.SuppressFinalize(this);
    }
 
    private AppHostServerProject CreateProject(string? appPath = null)
    {
        appPath ??= _workspace.WorkspaceRoot.FullName;
        var runner = new TestDotNetCliRunner();
        var packagingService = new MockPackagingService();
        var configurationService = new TrackingConfigurationService();
        var logger = NullLogger<AppHostServerProject>.Instance;
 
        return new AppHostServerProject(appPath, runner, packagingService, configurationService, logger);
    }
 
    /// <summary>
    /// Normalizes a generated csproj for snapshot comparison by replacing dynamic values.
    /// </summary>
    private static string NormalizeCsprojForSnapshot(string csprojContent, AppHostServerProject project)
    {
        // Replace dynamic UserSecretsId with placeholder
        return csprojContent.Replace(project.UserSecretsId, "{USER_SECRETS_ID}");
    }
 
    [Fact]
    public async Task CreateProjectFiles_ProductionCsproj_MatchesSnapshot()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0"),
            ("Aspire.Hosting.AppHost", "13.1.0"),
            ("Aspire.Hosting.Redis", "13.1.0"),
            ("Aspire.Hosting.PostgreSQL", "13.1.0"),
            ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var csprojContent = await File.ReadAllTextAsync(projectPath);
        var normalized = NormalizeCsprojForSnapshot(csprojContent, project);
 
        await Verify(normalized, extension: "xml")
            .UseFileName("AppHostServerProject_ProductionCsproj");
    }
 
    [Fact]
    public async Task CreateProjectFiles_AppSettingsJson_MatchesSnapshot()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0"),
            ("Aspire.Hosting.Redis", "13.1.0"),
            ("Aspire.Hosting.PostgreSQL", "13.1.0"),
            ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var appSettingsPath = Path.Combine(project.ProjectModelPath, "appsettings.json");
        var content = await File.ReadAllTextAsync(appSettingsPath);
 
        await Verify(content, extension: "json")
            .UseFileName("AppHostServerProject_AppSettingsJson");
    }
 
    [Fact]
    public async Task CreateProjectFiles_ProgramCs_MatchesSnapshot()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var programCsPath = Path.Combine(project.ProjectModelPath, "Program.cs");
        var content = await File.ReadAllTextAsync(programCsPath);
 
        // Use .txt extension to avoid compilation of snapshot file
        await Verify(content, extension: "txt")
            .UseFileName("AppHostServerProject_ProgramCs");
    }
 
    [Fact]
    public async Task CreateProjectFiles_GeneratesProductionCsproj_WithAspireSdk()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0"),
            ("Aspire.Hosting.AppHost", "13.1.0"),
            ("Aspire.Hosting.Redis", "13.1.0"),
            ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        Assert.True(File.Exists(projectPath));
        var doc = XDocument.Load(projectPath);
 
        // Verify SDK attribute
        var sdkAttr = doc.Root?.Attribute("Sdk")?.Value;
        Assert.Equal("Aspire.AppHost.Sdk/13.1.0", sdkAttr);
    }
 
    [Fact]
    public async Task CreateProjectFiles_ProductionMode_FiltersOutImplicitPackages()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0"),
            ("Aspire.Hosting.AppHost", "13.1.0"),
            ("Aspire.Hosting.Redis", "13.1.0"),
            ("Aspire.Hosting.PostgreSQL", "13.1.0"),
            ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var doc = XDocument.Load(projectPath);
        var packageRefs = doc.Descendants("PackageReference")
            .Select(e => e.Attribute("Include")?.Value)
            .Where(v => v is not null)
            .ToList();
 
        // Aspire.Hosting and Aspire.Hosting.AppHost should NOT be in package references (SDK provides them)
        Assert.DoesNotContain("Aspire.Hosting", packageRefs);
        Assert.DoesNotContain("Aspire.Hosting.AppHost", packageRefs);
 
        // Integration packages and code gen should be present
        Assert.Contains("Aspire.Hosting.Redis", packageRefs);
        Assert.Contains("Aspire.Hosting.PostgreSQL", packageRefs);
        Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript", packageRefs);
 
        // RemoteHost should always be added
        Assert.Contains("Aspire.Hosting.RemoteHost", packageRefs);
    }
 
    [Theory]
    [InlineData("Aspire.Hosting", false)]
    [InlineData("Aspire.Hosting.AppHost", false)]
    [InlineData("Aspire.Hosting.Redis", true)]
    [InlineData("Aspire.Hosting.PostgreSQL", true)]
    [InlineData("Aspire.Hosting.RemoteHost", true)]
    [InlineData("Aspire.Hosting.CodeGeneration.TypeScript", true)]
    [InlineData("Aspire.Hosting.CodeGeneration.Python", true)]
    public async Task CreateProjectFiles_ProductionMode_CorrectlyFiltersPackages(string packageName, bool shouldBeIncluded)
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0"),
            ("Aspire.Hosting.AppHost", "13.1.0"),
            (packageName, "13.1.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var doc = XDocument.Load(projectPath);
        var packageRefs = doc.Descendants("PackageReference")
            .Select(e => e.Attribute("Include")?.Value)
            .Where(v => v is not null)
            .ToList();
 
        if (shouldBeIncluded)
        {
            Assert.Contains(packageName, packageRefs);
        }
        else
        {
            Assert.DoesNotContain(packageName, packageRefs);
        }
    }
 
    [Fact]
    public async Task CreateProjectFiles_ProductionMode_AlwaysAddsRemoteHost()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0"),
            ("Aspire.Hosting.AppHost", "13.1.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var doc = XDocument.Load(projectPath);
        var packageRefs = doc.Descendants("PackageReference")
            .Select(e => e.Attribute("Include")?.Value)
            .Where(v => v is not null)
            .ToList();
 
        // RemoteHost should always be present even if not in input packages
        Assert.Contains("Aspire.Hosting.RemoteHost", packageRefs);
    }
 
    [Fact]
    public async Task CreateProjectFiles_GeneratesProgramCs()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var programCs = Path.Combine(project.ProjectModelPath, "Program.cs");
        Assert.True(File.Exists(programCs));
 
        var content = await File.ReadAllTextAsync(programCs);
        Assert.Contains("RemoteHostServer.RunAsync", content);
    }
 
    [Fact]
    public async Task CreateProjectFiles_GeneratesAppSettingsJson_WithAtsAssemblies()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0"),
            ("Aspire.Hosting.Redis", "13.1.0"),
            ("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var appSettingsPath = Path.Combine(project.ProjectModelPath, "appsettings.json");
        Assert.True(File.Exists(appSettingsPath));
 
        var content = await File.ReadAllTextAsync(appSettingsPath);
        Assert.Contains("AtsAssemblies", content);
        Assert.Contains("Aspire.Hosting", content);
        Assert.Contains("Aspire.Hosting.Redis", content);
        Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript", content);
    }
 
    [Fact]
    public async Task CreateProjectFiles_ProductionMode_HasMinimalProperties()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var doc = XDocument.Load(projectPath);
 
        // Should have minimal property group
        var propertyGroup = doc.Descendants("PropertyGroup").First();
 
        Assert.NotNull(propertyGroup.Element("OutputType"));
        Assert.NotNull(propertyGroup.Element("TargetFramework"));
        Assert.NotNull(propertyGroup.Element("AssemblyName"));
        Assert.NotNull(propertyGroup.Element("OutDir"));
        Assert.NotNull(propertyGroup.Element("UserSecretsId"));
        Assert.NotNull(propertyGroup.Element("IsAspireHost"));
 
        // Should NOT have dev-mode only properties
        Assert.Null(propertyGroup.Element("IsPublishable"));
        Assert.Null(propertyGroup.Element("SelfContained"));
        Assert.Null(propertyGroup.Element("NoWarn"));
        Assert.Null(propertyGroup.Element("RepoRoot"));
    }
 
    [Fact]
    public async Task CreateProjectFiles_ProductionMode_DisablesCodeGeneration()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var doc = XDocument.Load(projectPath);
 
        // Should have empty targets to disable code generation
        var targets = doc.Descendants("Target").ToList();
        Assert.Contains(targets, t => t.Attribute("Name")?.Value == "_CSharpWriteHostProjectMetadataSources");
        Assert.Contains(targets, t => t.Attribute("Name")?.Value == "_CSharpWriteProjectMetadataSources");
    }
 
    [Fact]
    public async Task CreateProjectFiles_CopiesAppSettingsToOutput()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Assert
        var doc = XDocument.Load(projectPath);
 
        var noneElement = doc.Descendants("None")
            .FirstOrDefault(e => e.Attribute("Include")?.Value == "appsettings.json");
 
        Assert.NotNull(noneElement);
        Assert.Equal("PreserveNewest", noneElement.Attribute("CopyToOutputDirectory")?.Value);
    }
 
    [Fact]
    public void DefaultSdkVersion_ReturnsValidVersion()
    {
        // Act
        var version = AppHostServerProject.DefaultSdkVersion;
 
        // Assert
        Assert.NotNull(version);
        Assert.NotEmpty(version);
        // Should not contain '+' (commit hash should be stripped)
        Assert.DoesNotContain("+", version);
    }
 
    [Fact]
    public void ProjectModelPath_IsStableForSameAppPath()
    {
        // Arrange
        var appPath = _workspace.WorkspaceRoot.FullName;
 
        // Act
        var project1 = CreateProject(appPath);
        var project2 = CreateProject(appPath);
 
        // Assert - same app path should result in same project model path
        Assert.Equal(project1.ProjectModelPath, project2.ProjectModelPath);
    }
 
    [Fact]
    public void UserSecretsId_IsStableForSameAppPath()
    {
        // Arrange
        var appPath = _workspace.WorkspaceRoot.FullName;
 
        // Act
        var project1 = CreateProject(appPath);
        var project2 = CreateProject(appPath);
 
        // Assert - same app path should result in same user secrets ID
        Assert.Equal(project1.UserSecretsId, project2.UserSecretsId);
    }
 
    [Fact]
    public async Task CreateProjectFiles_UsesSdkVersionInPackageAttribute()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.2.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync("13.2.0", packages);
 
        // Assert
        var doc = XDocument.Load(projectPath);
        var sdkAttr = doc.Root?.Attribute("Sdk")?.Value;
        Assert.Equal("Aspire.AppHost.Sdk/13.2.0", sdkAttr);
    }
 
    [Fact]
    public async Task CreateProjectFiles_PackageVersionsMatchSdkVersion()
    {
        // Arrange
        var project = CreateProject();
        var sdkVersion = "13.3.0";
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", sdkVersion),
            ("Aspire.Hosting.Redis", sdkVersion)
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync(sdkVersion, packages);
 
        // Assert
        var doc = XDocument.Load(projectPath);
 
        // RemoteHost should use SDK version
        var remoteHostRef = doc.Descendants("PackageReference")
            .FirstOrDefault(e => e.Attribute("Include")?.Value == "Aspire.Hosting.RemoteHost");
 
        Assert.NotNull(remoteHostRef);
        Assert.Equal(sdkVersion, remoteHostRef.Attribute("Version")?.Value);
    }
 
    /// <summary>
    /// Regression test for channel switching bug.
    /// When a project has a channel configured in .aspire/settings.json (project-local),
    /// the NuGet.config should use that channel's hive path, NOT the global config channel.
    /// 
    /// Bug scenario:
    /// 1. User runs `aspire update` and selects "pr-new" channel
    /// 2. UpdatePackagesAsync saves channel="pr-new" to project-local .aspire/settings.json
    /// 3. BuildAndGenerateSdkAsync calls CreateProjectFilesAsync
    /// 4. BUG: CreateProjectFilesAsync reads channel from GLOBAL config (returns "pr-old")
    /// 5. NuGet.config is generated with pr-old hive path instead of pr-new
    /// 6. Build fails because packages are in pr-new hive but NuGet.config points to pr-old
    /// </summary>
    [Fact]
    public async Task CreateProjectFiles_NuGetConfig_UsesProjectLocalChannel_NotGlobalChannel_MatchesSnapshot()
    {
        // Arrange
        var appPath = _workspace.WorkspaceRoot.FullName;
 
        // Create two PR hive directories to simulate having multiple PR builds
        var hivesDir = _workspace.WorkspaceRoot.CreateSubdirectory("hives");
        var prOldHive = hivesDir.CreateSubdirectory("pr-old");
        var prNewHive = hivesDir.CreateSubdirectory("pr-new");
 
        // Create project-local .aspire/settings.json with channel="pr-new"
        // This simulates what happens after `aspire update` saves the selected channel
        var aspireDir = _workspace.WorkspaceRoot.CreateSubdirectory(".aspire");
        var settingsJson = Path.Combine(aspireDir.FullName, "settings.json");
        await File.WriteAllTextAsync(settingsJson, """
            {
                "channel": "pr-new",
                "sdkVersion": "13.1.0"
            }
            """);
 
        // Configure global config to return "pr-old" (the WRONG channel)
        // This simulates a stale global config that hasn't been updated
        var configurationService = new TrackingConfigurationService
        {
            OnGetConfiguration = key => key == "channel" ? "pr-old" : null
        };
 
        // Create a packaging service that returns explicit channels for both PR hives
        var packagingService = new MockPackagingServiceWithExplicitChannels(
            prOldHive.FullName,
            prNewHive.FullName);
 
        var runner = new TestDotNetCliRunner();
        
        // Use a real logger to capture debug output for diagnostics
        using var loggerFactory = LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Debug);
            builder.AddXunit(outputHelper);
        });
        var logger = loggerFactory.CreateLogger<AppHostServerProject>();
 
        // Use a workspace-local ProjectModelPath for test isolation
        var projectModelPath = Path.Combine(appPath, ".aspire_server");
        var project = new AppHostServerProject(appPath, runner, packagingService, configurationService, logger, projectModelPath);
 
        var packages = new List<(string Name, string Version)>
        {
            ("Aspire.Hosting", "13.1.0"),
            ("Aspire.Hosting.AppHost", "13.1.0"),
            ("Aspire.Hosting.Redis", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync("13.1.0", packages);
 
        // Dump workspace directory tree for debugging
        outputHelper.WriteLine("=== Workspace Directory Tree ===");
        DumpDirectoryTree(appPath, outputHelper);
        outputHelper.WriteLine("================================");
 
        // Also dump ProjectModelPath content
        outputHelper.WriteLine($"=== ProjectModelPath ({project.ProjectModelPath}) ===");
        if (Directory.Exists(project.ProjectModelPath))
        {
            DumpDirectoryTree(project.ProjectModelPath, outputHelper);
        }
        else
        {
            outputHelper.WriteLine("  (directory does not exist)");
        }
        outputHelper.WriteLine("================================");
 
        // Assert - verify nuget.config uses the correct channel
        // Note: NuGetConfigMerger creates the file as "nuget.config" (lowercase)
        var nugetConfigPath = Path.Combine(project.ProjectModelPath, "nuget.config");
        
        // Build diagnostic info for assertion failure
        var diagnosticInfo = new System.Text.StringBuilder();
        diagnosticInfo.AppendLine($"appPath: {appPath}");
        diagnosticInfo.AppendLine($"settingsJson path: {settingsJson}");
        diagnosticInfo.AppendLine($"settingsJson exists: {File.Exists(settingsJson)}");
        if (File.Exists(settingsJson))
        {
            diagnosticInfo.AppendLine($"settingsJson content: {File.ReadAllText(settingsJson)}");
        }
        diagnosticInfo.AppendLine($"project.ProjectModelPath: {project.ProjectModelPath}");
        diagnosticInfo.AppendLine($"nugetConfigPath: {nugetConfigPath}");
        diagnosticInfo.AppendLine($"nugetConfigPath exists: {File.Exists(nugetConfigPath)}");
        
        // List all files for debugging case sensitivity issues
        if (Directory.Exists(project.ProjectModelPath))
        {
            diagnosticInfo.AppendLine("Files in ProjectModelPath:");
            foreach (var file in Directory.GetFiles(project.ProjectModelPath))
            {
                diagnosticInfo.AppendLine($"  - {Path.GetFileName(file)}");
            }
        }
        
        // The nuget.config should exist
        Assert.True(File.Exists(nugetConfigPath), $"nuget.config should be created\n\nDiagnostics:\n{diagnosticInfo}");
        
        var nugetConfigContent = await File.ReadAllTextAsync(nugetConfigPath);
 
        // Normalize paths for snapshot (replace machine-specific paths)
        var normalizedContent = nugetConfigContent
            .Replace(prNewHive.FullName, "{PR_NEW_HIVE}")
            .Replace(prOldHive.FullName, "{PR_OLD_HIVE}");
 
        // Snapshot verification - this will fail if the bug exists
        // Expected: Contains {PR_NEW_HIVE} (project-local channel)
        // Bug behavior: Contains {PR_OLD_HIVE} (global config channel)
        await Verify(normalizedContent, extension: "xml")
            .UseFileName("AppHostServerProject_NuGetConfig_UsesProjectLocalChannel");
    }
 
    /// <summary>
    /// Mock packaging service that returns explicit PR channels with specific hive paths.
    /// Used to test that the correct channel is selected based on project-local settings.
    /// </summary>
    private sealed class MockPackagingServiceWithExplicitChannels : IPackagingService
    {
        private readonly string _prOldHivePath;
        private readonly string _prNewHivePath;
 
        public MockPackagingServiceWithExplicitChannels(string prOldHivePath, string prNewHivePath)
        {
            _prOldHivePath = prOldHivePath;
            _prNewHivePath = prNewHivePath;
        }
 
        public Task<IEnumerable<PackageChannel>> GetChannelsAsync(CancellationToken cancellationToken = default)
        {
            var nugetCache = new FakeNuGetPackageCache();
 
            // Create explicit channels for both PR hives
            var prOldChannel = PackageChannel.CreateExplicitChannel("pr-old", PackageChannelQuality.Prerelease, new[]
            {
                new PackageMapping("Aspire*", _prOldHivePath),
                new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
            }, nugetCache);
 
            var prNewChannel = PackageChannel.CreateExplicitChannel("pr-new", PackageChannelQuality.Prerelease, new[]
            {
                new PackageMapping("Aspire*", _prNewHivePath),
                new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
            }, nugetCache);
 
            var implicitChannel = PackageChannel.CreateImplicitChannel(nugetCache);
 
            return Task.FromResult<IEnumerable<PackageChannel>>(new[] { implicitChannel, prOldChannel, prNewChannel });
        }
    }
 
    private sealed class FakeNuGetPackageCache : INuGetPackageCache
    {
        public Task<IEnumerable<NuGetPackageCli>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
            => Task.FromResult<IEnumerable<NuGetPackageCli>>([]);
 
        public Task<IEnumerable<NuGetPackageCli>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
            => Task.FromResult<IEnumerable<NuGetPackageCli>>([]);
 
        public Task<IEnumerable<NuGetPackageCli>> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
            => Task.FromResult<IEnumerable<NuGetPackageCli>>([]);
 
        public Task<IEnumerable<NuGetPackageCli>> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func<string, bool>? filter, bool prerelease, FileInfo? nugetConfigFile, bool useCache, CancellationToken cancellationToken)
            => Task.FromResult<IEnumerable<NuGetPackageCli>>([]);
    }
 
    private static void DumpDirectoryTree(string path, ITestOutputHelper output, string indent = "")
    {
        var dirInfo = new DirectoryInfo(path);
        output.WriteLine($"{indent}{dirInfo.Name}/");
        
        foreach (var file in dirInfo.GetFiles())
        {
            output.WriteLine($"{indent}  {file.Name}");
        }
        
        foreach (var dir in dirInfo.GetDirectories())
        {
            DumpDirectoryTree(dir.FullName, output, indent + "  ");
        }
    }
}