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 Microsoft.AspNetCore.InternalTesting;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using Aspire.Cli.Configuration;
using Aspire.Cli.Packaging;
using Aspire.Cli.Projects;
using Aspire.Cli.Utils;
using Aspire.Cli.Tests.Mcp;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
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.CreateForCli(outputHelper);
 
    public void Dispose()
    {
        _workspace.Dispose();
        GC.SuppressFinalize(this);
    }
 
    private DotNetBasedAppHostServerProject CreateProject(string? appPath = null)
    {
        appPath ??= _workspace.WorkspaceRoot.FullName;
        var runner = new TestDotNetCliRunner();
        var packagingService = MockPackagingServiceFactory.Create();
        var logger = NullLogger<DotNetBasedAppHostServerProject>.Instance;
 
        // Generate socket path same way as factory
        var socketPath = "test.sock";
 
        // Use workspace root as repo root for testing
        var repoRoot = _workspace.WorkspaceRoot.FullName;
 
        return new DotNetBasedAppHostServerProject(appPath, socketPath, repoRoot, runner, packagingService, new TestProcessExecutionFactory(), new TestEnvironment(), logger);
    }
 
        [Fact]
        public async Task CreateProjectFiles_RestoreRootConfigDirectory_PreservesRelativeSources()
        {
                var repoRoot = _workspace.CreateDirectory("repo");
                var appPath = _workspace.CreateDirectory("app");
                var projectModelPath = _workspace.CreateDirectory("model");
                var parentFeed = _workspace.CreateDirectory("parent-feed");
                var parentConfigPath = Path.Combine(_workspace.Path, "NuGet.Config");
                await File.WriteAllTextAsync(parentConfigPath, """
                        <configuration>
                            <packageSources>
                                <clear />
                                <add key="parent-feed" value="./parent-feed" />
                            </packageSources>
                            <packageSourceMapping>
                                <clear />
                                <packageSource key="parent-feed">
                                    <package pattern="*" />
                                </packageSource>
                            </packageSourceMapping>
                        </configuration>
                        """);
                var feed = repoRoot.CreateSubdirectory("feed");
                var repoConfigPath = Path.Combine(repoRoot.FullName, "NuGet.Config");
                await File.WriteAllTextAsync(repoConfigPath, """
                        <configuration>
                            <packageSources>
                                <add key="repo-feed" value="./feed" />
                            </packageSources>
                            <packageSourceMapping>
                                <packageSource key="repo-feed">
                                    <package pattern="*" />
                                </packageSource>
                            </packageSourceMapping>
                        </configuration>
                        """);
                await File.WriteAllTextAsync(Path.Combine(repoRoot.FullName, "Directory.Packages.props"), "<Project />");
                await File.WriteAllTextAsync(Path.Combine(appPath.FullName, "NuGet.Config"), """
                        <configuration>
                            <packageSources>
                                <clear />
                                <add key="app-feed" value="./other-feed" />
                            </packageSources>
                        </configuration>
                        """);
                var packagingService = new TestPackagingService
                {
                        GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>([])
                };
                var project = new DotNetBasedAppHostServerProject(
                        appPath.FullName, "test.sock", repoRoot.FullName,
                        new TestDotNetCliRunner(), packagingService,
                        new TestProcessExecutionFactory(), new TestEnvironment(),
                        NullLogger<DotNetBasedAppHostServerProject>.Instance,
                        projectModelPath: projectModelPath.FullName,
                        restoreRootConfigDirectory: repoRoot.FullName);
 
                var (projectFilePath, _) = await project.CreateProjectFilesAsync([]).DefaultTimeout();
 
                Assert.Equal(repoRoot.FullName, XDocument.Load(projectFilePath).Descendants("RestoreRootConfigDirectory").Single().Value);
                Assert.False(NuGetConfigMerger.TryFindNuGetConfigInDirectory(projectModelPath, out _));
 
                // Evaluate NuGet's actual settings without restoring packages. A relative source such as
                // <add key="repo-feed" value="./feed" /> must resolve beside the original config file.
                var startInfo = new ProcessStartInfo("dotnet")
                {
                        WorkingDirectory = projectModelPath.FullName,
                        UseShellExecute = false,
                        RedirectStandardOutput = true,
                        RedirectStandardError = true,
                        CreateNoWindow = true,
                        ArgumentList =
                        {
                                "msbuild", projectFilePath, "-nologo", "-target:_GetRestoreSettings",
                                "-property:RestoreProjectStyle=PackageReference",
                                "-getProperty:_OutputSources,_OutputConfigFilePaths"
                        }
                };
                using var process = Process.Start(startInfo);
                Assert.NotNull(process);
                try
                {
                        var outputTask = process.StandardOutput.ReadToEndAsync();
                        var errorTask = process.StandardError.ReadToEndAsync();
                        await process.WaitForExitAsync().DefaultTimeout();
                        var output = await outputTask.DefaultTimeout();
                        var error = await errorTask.DefaultTimeout();
                        Assert.True(process.ExitCode == 0, $"{output}{Environment.NewLine}{error}");
 
                        using var document = JsonDocument.Parse(output);
                        var properties = document.RootElement.GetProperty("Properties");
                        var sources = properties.GetProperty("_OutputSources").GetString()!.Split(';');
                        Assert.Equal(new[] { parentFeed.FullName, feed.FullName }.Order(StringComparer.Ordinal), sources.Order(StringComparer.Ordinal));
                        var configPaths = properties.GetProperty("_OutputConfigFilePaths").GetString()!.Split(';');
                        Assert.Contains(repoConfigPath, configPaths);
                        Assert.Contains(parentConfigPath, configPaths);
                }
                finally
                {
                        if (!process.HasExited)
                        {
                                process.Kill(entireProcessTree: true);
                        }
                }
        }
 
        [Fact]
        public async Task CreateProjectFiles_AppSettingsJson_MatchesSnapshot()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<IntegrationReference>
        {
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"),
            IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0"),
            IntegrationReference.FromPackage("Aspire.Hosting.PostgreSQL", "13.1.0"),
            IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync(packages).DefaultTimeout();
 
        // 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<IntegrationReference>
        {
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync(packages).DefaultTimeout();
 
        // 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_GeneratesProgramCs()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<IntegrationReference>
        {
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync(packages).DefaultTimeout();
 
        // 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<IntegrationReference>
        {
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"),
            IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0"),
            IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync(packages).DefaultTimeout();
 
        // 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_CopiesAppSettingsToOutput()
    {
        // Arrange
        var project = CreateProject();
        var packages = new List<IntegrationReference>
        {
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
        };
 
        // Act
        var (projectPath, _) = await project.CreateProjectFilesAsync(packages).DefaultTimeout();
 
        // 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 async Task CreateProjectFiles_SkipsAspireIntegrationAnalyzerReferences()
    {
        var project = CreateProject();
        var packages = new List<IntegrationReference>
        {
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
        };
 
        var (projectPath, _) = await project.CreateProjectFilesAsync(packages).DefaultTimeout();
 
        var doc = XDocument.Load(projectPath);
        var skipAnalyzersElement = doc.Descendants("SkipAspireIntegrationAnalyzersReference").SingleOrDefault();
 
        Assert.NotNull(skipAnalyzersElement);
        Assert.Equal("true", skipAnalyzersElement.Value);
    }
 
    [Fact]
    public async Task CreateProjectFiles_ExactAspirePackageRestoresInsteadOfUsingCheckoutProject()
    {
        var project = CreateProject();
        var integrations = new[]
        {
            IntegrationReference.FromPackage(
                "Aspire.Hosting.Redis",
                "[13.1.0]",
                disableLocalProjectSubstitution: true),
            IntegrationReference.FromPackage("Aspire.Hosting.PostgreSQL", "13.1.0")
        };
 
        var (projectPath, _) = await project.CreateProjectFilesAsync(integrations).DefaultTimeout();
 
        var document = XDocument.Load(projectPath);
        var packageReference = Assert.Single(
            document.Descendants("PackageReference"),
            element => element.Attribute("Include")?.Value == "Aspire.Hosting.Redis");
 
        Assert.Equal("[13.1.0]", packageReference.Attribute("VersionOverride")?.Value);
        Assert.Null(packageReference.Attribute("Version"));
        Assert.DoesNotContain(
            document.Descendants("PackageReference"),
            element => element.Attribute("Include")?.Value == "Aspire.Hosting.PostgreSQL");
    }
 
    [Fact]
    public async Task CreateProjectFiles_ExactVersionRangeUsesCheckoutProjectByDefault()
    {
        var integrationDirectory = _workspace.WorkspaceRoot.CreateSubdirectory(
            Path.Combine("src", "Aspire.Hosting.Redis"));
        var integrationProjectPath = Path.Combine(integrationDirectory.FullName, "Aspire.Hosting.Redis.csproj");
        await File.WriteAllTextAsync(integrationProjectPath, "<Project />");
 
        var project = CreateProject();
        var integrations = new[]
        {
            IntegrationReference.FromPackage("Aspire.Hosting.Redis", "[13.1.0]")
        };
 
        var (projectPath, _) = await project.CreateProjectFilesAsync(integrations).DefaultTimeout();
 
        var document = XDocument.Load(projectPath);
        var projectReference = Assert.Single(
            document.Descendants("ProjectReference"),
            element => element.Attribute("Include")?.Value == integrationProjectPath);
 
        Assert.Equal("false", projectReference.Element("IsAspireProjectResource")?.Value);
        Assert.DoesNotContain(
            document.Descendants("PackageReference"),
            element => element.Attribute("Include")?.Value == "Aspire.Hosting.Redis");
    }
 
    [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 ProjectModelPath_UsesUserAspireDirectory()
    {
        // Arrange
        var project = CreateProject();
        var normalizedAppPath = Path.GetFullPath(project.AppDirectoryPath);
        normalizedAppPath = new Uri(normalizedAppPath).LocalPath;
        normalizedAppPath = OperatingSystem.IsWindows() ? normalizedAppPath.ToLowerInvariant() : normalizedAppPath;
 
        var pathHash = SHA256.HashData(Encoding.UTF8.GetBytes(normalizedAppPath));
        var pathDir = Convert.ToHexString(pathHash)[..12].ToLowerInvariant();
        var expectedRoot = Path.Combine(CliPathHelper.GetAspireHomeDirectory(), "hosts");
        var expectedPath = Path.Combine(expectedRoot, pathDir);
        var parentDirectory = Path.GetDirectoryName(project.ProjectModelPath);
        var isSafeToDelete = string.Equals(project.ProjectModelPath, expectedPath, StringComparison.OrdinalIgnoreCase) &&
            parentDirectory is not null &&
            string.Equals(parentDirectory, expectedRoot, StringComparison.OrdinalIgnoreCase);
 
        try
        {
            // Assert
            Assert.Equal(expectedPath, project.ProjectModelPath);
        }
        finally
        {
            if (isSafeToDelete && Directory.Exists(project.ProjectModelPath))
            {
                Directory.Delete(project.ProjectModelPath, recursive: true);
            }
        }
    }
 
    [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);
    }
 
    /// <summary>
    /// Regression test for channel switching bug.
    /// When a project has a channel configured in aspire.config.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.config.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.config.json with channel="pr-new"
        // This simulates what happens after `aspire update` saves the selected channel
        var aspireConfigPath = Path.Combine(_workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(aspireConfigPath, """
            {
                "channel": "pr-new",
                "sdk": {
                    "version": "13.1.0"
                }
            }
            """);
 
        // Create a packaging service that returns explicit channels for both PR hives
        var prOldHivePath = prOldHive.FullName;
        var prNewHivePath = prNewHive.FullName;
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ =>
            {
                var nugetCache = new FakeNuGetPackageCache();
 
                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, new TestFeatures(), NullLogger.Instance);
 
                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, new TestFeatures(), NullLogger.Instance);
 
                var implicitChannel = PackageChannel.CreateImplicitChannel(nugetCache, new TestFeatures(), NullLogger.Instance);
 
                return Task.FromResult<IEnumerable<PackageChannel>>(new[] { implicitChannel, prOldChannel, prNewChannel });
            }
        };
 
        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<DotNetBasedAppHostServerProject>();
 
        // Use a workspace-local ProjectModelPath for test isolation
        var projectModelPath = Path.Combine(appPath, ".aspire_server");
        var project = new DotNetBasedAppHostServerProject(appPath, "test.sock", appPath, runner, packagingService, new TestProcessExecutionFactory(), new TestEnvironment(), logger, projectModelPath);
 
        var packages = new List<IntegrationReference>
        {
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0"),
            IntegrationReference.FromPackage("Aspire.Hosting.AppHost", "13.1.0"),
            IntegrationReference.FromPackage("Aspire.Hosting.Redis", "13.1.0")
        };
 
        // Act
        await project.CreateProjectFilesAsync(packages).DefaultTimeout();
 
        // 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 the csproj uses RestoreAdditionalProjectSources with the correct channel sources
        var projectFilePath = Path.Combine(project.ProjectModelPath, DotNetBasedAppHostServerProject.ProjectFileName);
 
        Assert.True(File.Exists(projectFilePath), $"Project file should be created at {projectFilePath}");
 
        var projectContent = await File.ReadAllTextAsync(projectFilePath);
        var projectDoc = XDocument.Parse(projectContent);
        var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value;
 
        // Should contain the pr-new hive path (project-local channel), NOT pr-old (global config)
        Assert.NotNull(restoreSources);
        Assert.Contains(prNewHive.FullName, restoreSources);
        Assert.DoesNotContain(prOldHive.FullName, restoreSources);
    }
 
    [Fact]
    public async Task CreateProjectFiles_WithPackageSourceOverride_PrependsOverrideToRestoreAdditionalProjectSources()
    {
        // Regression for finding #2 of the 2026-05-19 post-merge review: the DotNet-based
        // (in-repo / dogfood) AppHost path previously declared a packageSourceOverride parameter
        // on PrepareAsync but ignored it during restore, so `aspire new --source <pr-hive>` was
        // silently dropped in dev mode. The override now threads through CreateProjectFilesAsync
        // and prepends to the RestoreAdditionalProjectSources list so the dogfood hive is the
        // first source NuGet evaluates for any PackageReference fallback in this path.
        var appPath = _workspace.WorkspaceRoot.FullName;
        const string overrideSource = "/tmp/aspire-pr-hive/packages";
        var aspireConfigPath = Path.Combine(appPath, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(aspireConfigPath, """
            {
                "channel": "daily"
            }
            """);
 
        var nugetCache = new FakeNuGetPackageCache();
        var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, new[]
        {
            new PackageMapping("Aspire*", "https://pkgs.dev.azure.com/fake/v3/index.json"),
            new PackageMapping(PackageMapping.AllPackages, "https://api.nuget.org/v3/index.json")
        }, nugetCache, new TestFeatures(), NullLogger.Instance);
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(new[] { dailyChannel })
        };
 
        var projectModelPath = Path.Combine(appPath, ".aspire_server");
        var project = new DotNetBasedAppHostServerProject(
            appPath,
            "test.sock",
            appPath,
            new TestDotNetCliRunner(),
            packagingService,
            new TestProcessExecutionFactory(),
            new TestEnvironment(),
            NullLogger<DotNetBasedAppHostServerProject>.Instance,
            projectModelPath);
 
        var packages = new List<IntegrationReference>
        {
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
        };
 
        var (projectFilePath, _) = await project.CreateProjectFilesAsync(packages, packageSourceOverride: overrideSource, cancellationToken: CancellationToken.None).DefaultTimeout();
 
        var projectDoc = XDocument.Load(projectFilePath);
        var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value;
        Assert.NotNull(restoreSources);
        var sources = restoreSources!.Split(';');
        // Override is prepended so the hive wins NuGet's source evaluation order when the same
        // Aspire package version exists in both the hive and the channel feed.
        Assert.Equal(overrideSource, sources[0]);
        Assert.Contains("https://pkgs.dev.azure.com/fake/v3/index.json", sources);
    }
 
    [Fact]
    public async Task CreateProjectFiles_WithoutPackageSourceOverride_DoesNotInjectExtraSource()
    {
        // Negative companion to the override regression: ensure the no-override path still emits
        // only the channel sources (i.e., we are not accidentally introducing an empty/null
        // source that breaks restore on the existing in-repo flow).
        var appPath = _workspace.WorkspaceRoot.FullName;
        var aspireConfigPath = Path.Combine(appPath, AspireConfigFile.FileName);
        await File.WriteAllTextAsync(aspireConfigPath, """
            {
                "channel": "daily"
            }
            """);
 
        var nugetCache = new FakeNuGetPackageCache();
        const string channelFeed = "https://pkgs.dev.azure.com/fake/v3/index.json";
        var dailyChannel = PackageChannel.CreateExplicitChannel("daily", PackageChannelQuality.Prerelease, new[]
        {
            new PackageMapping("Aspire*", channelFeed)
        }, nugetCache, new TestFeatures(), NullLogger.Instance);
        var packagingService = new TestPackagingService
        {
            GetChannelsAsyncCallback = _ => Task.FromResult<IEnumerable<PackageChannel>>(new[] { dailyChannel })
        };
 
        var projectModelPath = Path.Combine(appPath, ".aspire_server");
        var project = new DotNetBasedAppHostServerProject(
            appPath,
            "test.sock",
            appPath,
            new TestDotNetCliRunner(),
            packagingService,
            new TestProcessExecutionFactory(),
            new TestEnvironment(),
            NullLogger<DotNetBasedAppHostServerProject>.Instance,
            projectModelPath);
 
        var packages = new List<IntegrationReference>
        {
            IntegrationReference.FromPackage("Aspire.Hosting", "13.1.0")
        };
 
        var (projectFilePath, _) = await project.CreateProjectFilesAsync(packages).DefaultTimeout();
 
        var projectDoc = XDocument.Load(projectFilePath);
        var restoreSources = projectDoc.Descendants("RestoreAdditionalProjectSources").FirstOrDefault()?.Value;
        Assert.NotNull(restoreSources);
        Assert.Equal(channelFeed, restoreSources);
    }
 
    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 + "  ");
        }
    }
}