File: IntegrationSourceTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.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 System.Text.Json.Nodes;
using System.Xml.Linq;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
public sealed class IntegrationSourceTests(ITestOutputHelper output)
{
    [Theory]
    [InlineData(false, false, false)]
    [InlineData(true, false, false)]
    [InlineData(true, true, false)]
    [InlineData(true, true, true)]
    public void MappedConfigurationIncludesPublicDependencySource(bool hasConfiguration, bool hasHiveSource, bool hasPublicSource)
    {
        var originalConfig = hasConfiguration ? new XDocument(new XElement("configuration")) : null;
        if (hasHiveSource || hasPublicSource)
        {
            var sources = new XElement("packageSources");
            if (hasHiveSource)
            {
                sources.Add(new XElement("add", new XAttribute("key", "aspire-apphost-test"),
                    new XAttribute("value", "https://example.test/cli/v3/index.json")));
            }
            if (hasPublicSource)
            {
                sources.Add(new XElement("add", new XAttribute("key", "nuget.org"),
                    new XAttribute("value", "https://api.nuget.org/v3/index.json")));
            }
            originalConfig!.Root!.Add(sources);
        }
 
        var configuration = CreateMappedNuGetConfiguration(originalConfig);
 
        string[] expectedKeys = hasHiveSource ? ["aspire-apphost-test", "nuget.org"] : ["nuget.org"];
        var configuredSources = configuration.Descendants("packageSources").Elements("add").ToArray();
        Assert.Equal(expectedKeys, configuredSources.Select(source => (string?)source.Attribute("key")));
        var publicSource = Assert.Single(configuredSources, source => (string?)source.Attribute("key") == "nuget.org");
        Assert.Equal("https://api.nuget.org/v3/index.json", (string?)publicSource.Attribute("value"));
        var mappings = Assert.Single(configuration.Descendants("packageSourceMapping"));
        Assert.Single(mappings.Elements("clear"));
        Assert.Equal(expectedKeys, mappings.Elements("packageSource").Select(source => (string?)source.Attribute("key")));
        Assert.All(mappings.Elements("packageSource"), source =>
            Assert.Equal(["*"], source.Elements("package").Select(package => (string?)package.Attribute("pattern"))));
    }
 
    [Theory]
    [InlineData("csharp", false)]
    [InlineData("csharp", true)]
    [InlineData("typescript", false)]
    [InlineData("typescript", true)]
    [CaptureWorkspaceOnFailure]
    public async Task Add_FromLocalIntegrationSourcePersistsNuGetConfiguration(string language, bool hasMappings)
    {
        const string packageId = "Contoso.Hosting.LocalSource";
        const string packageVersion = "1.0.0";
 
        var cancellationToken = TestContext.Current.CancellationToken;
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        Assert.SkipUnless(
            strategy.Mode is CliInstallMode.LocalHive or CliInstallMode.LocalArchive or CliInstallMode.PullRequest,
            "Integration sources require a current source build. Run with ASPIRE_E2E_ARCHIVE or in pull request CI.");
 
        using var workspace = TemporaryWorkspace.Create(output);
        var root = workspace.WorkspaceRoot.FullName;
        var feed = workspace.CreateDirectory("integration-feed");
        var containerFeed = CliE2ETestHelpers.ToContainerPath(feed.FullName, workspace);
        // This package exists only in the configured feed. A private, initially empty package
        // cache ensures a successful add cannot be satisfied by an earlier restore.
        using (var archive = ZipFile.Open(Path.Combine(feed.FullName, $"{packageId}.{packageVersion}.nupkg"), ZipArchiveMode.Create))
        {
            await using (var writer = new StreamWriter(archive.CreateEntry($"{packageId}.nuspec").Open()))
            {
                await writer.WriteAsync($"""
                    <?xml version="1.0"?>
                    <package>
                      <metadata>
                        <id>{packageId}</id>
                        <version>{packageVersion}</version>
                        <authors>Aspire Tests</authors>
                        <description>Local integration source fixture.</description>
                        <tags>aspire integration hosting polyglot</tags>
                      </metadata>
                    </package>
                    """);
            }
            archive.CreateEntry("lib/net10.0/_._");
        }
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace);
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
        await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, cancellationToken);
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
        await auto.InstallAspireCliAsync(strategy, counter);
        await auto.RunCommandAsync("export NUGET_PACKAGES=\"$ASPIRE_E2E_WORKSPACE/.nuget-packages\"", counter);
        var template = language == "csharp" ? "aspire-empty" : "aspire-ts-empty";
        await auto.RunCommandAsync(
            $"aspire new {template} --language {language} --name SourceApp --output SourceApp " +
            "--non-interactive --suppress-agent-init --localhost-tld false",
            counter, TimeSpan.FromMinutes(3));
        await auto.RunCommandAsync("cd SourceApp", counter);
        await auto.RunCommandAsync("aspire config set integrationSources.default.enabled false", counter);
        await auto.RunCommandAsync(
            "aspire config set integrationSources.local.source \"$ASPIRE_E2E_WORKSPACE/integration-feed\"", counter);
 
        var projectDirectory = Path.Combine(root, "SourceApp");
        var nugetConfigPath = Path.Combine(projectDirectory, "nuget.config");
        var originalConfig = File.Exists(nugetConfigPath) ? XDocument.Load(nugetConfigPath) : null;
        if (hasMappings)
        {
            originalConfig = CreateMappedNuGetConfiguration(originalConfig);
            originalConfig.Save(nugetConfigPath);
        }
        Assert.Equal(hasMappings, originalConfig?.Descendants("packageSourceMapping").Elements("packageSource").Any() ?? false);
        Assert.Empty(originalConfig?.Descendants("packageSources").Elements("add")
            .Where(source => (string?)source.Attribute("value") == containerFeed) ?? []);
        var packageCacheDirectory = Path.Combine(root, ".nuget-packages", packageId.ToLowerInvariant(), packageVersion);
        Assert.False(Directory.Exists(packageCacheDirectory));
 
        // Do not pass --source: discovery and installation must use integrationSources.
        await auto.RunCommandAsync(
            $"aspire add {packageId} --version {packageVersion} --non-interactive",
            counter, TimeSpan.FromMinutes(3));
 
        if (language == "csharp")
        {
            // File-based AppHosts record references as "#:package Package.Id@1.0.0".
            var packageDirective = Assert.Single(
                await File.ReadAllLinesAsync(Path.Combine(projectDirectory, "apphost.cs"), cancellationToken),
                line => line.StartsWith($"#:package {packageId}@", StringComparison.Ordinal));
            Assert.Equal($"#:package {packageId}@{packageVersion}", packageDirective);
            await auto.RunCommandAsync("dotnet build apphost.cs", counter, TimeSpan.FromMinutes(3));
        }
        else
        {
            var config = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(projectDirectory, "aspire.config.json"), cancellationToken))!;
            Assert.Equal(packageVersion, config["packages"]![packageId]!.GetValue<string>());
            await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(3));
        }
 
        var updatedConfig = XDocument.Load(nugetConfigPath);
        var addedSource = Assert.Single(updatedConfig.Descendants("packageSources").Elements("add"),
            source => (string?)source.Attribute("value") == containerFeed);
        var sourceKey = addedSource.Attribute("key")!.Value;
        var mappings = updatedConfig.Descendants("packageSourceMapping").Elements("packageSource");
        if (hasMappings)
        {
            var mapping = Assert.Single(mappings, source => (string?)source.Attribute("key") == sourceKey);
            Assert.Equal([packageId], mapping.Elements("package").Select(package => (string?)package.Attribute("pattern")));
        }
        else
        {
            Assert.Empty(mappings);
        }
 
        foreach (var originalSource in originalConfig?.Descendants("packageSources").Elements("add") ?? [])
        {
            Assert.Contains(updatedConfig.Descendants("packageSources").Elements("add"),
                source => XNode.DeepEquals(originalSource, source));
        }
        foreach (var originalMapping in originalConfig?.Descendants("packageSourceMapping").Elements("packageSource") ?? [])
        {
            Assert.Contains(updatedConfig.Descendants("packageSourceMapping").Elements("packageSource"),
                source => XNode.DeepEquals(originalMapping, source));
        }
 
        var metadata = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(packageCacheDirectory, ".nupkg.metadata"), cancellationToken))!;
        Assert.Equal(containerFeed, metadata["source"]!.GetValue<string>().TrimEnd('/'));
    }
 
    private static XDocument CreateMappedNuGetConfiguration(XDocument? originalConfig)
    {
        originalConfig ??= new XDocument(new XElement("configuration"));
        var sources = originalConfig.Root!.Element("packageSources");
        if (sources is null)
        {
            sources = new XElement("packageSources");
            originalConfig.Root.Add(sources);
        }
        // C# scaffolding can declare only its hive and inherit nuget.org. Declare
        // the public dependency source locally before replacing inherited mappings.
        if (!sources.Elements("add").Any(source =>
            string.Equals((string?)source.Attribute("key"), "nuget.org", StringComparison.OrdinalIgnoreCase)))
        {
            sources.Add(new XElement("add", new XAttribute("key", "nuget.org"),
                new XAttribute("value", "https://api.nuget.org/v3/index.json")));
        }
        originalConfig.Root!.Element("packageSourceMapping")?.Remove();
        originalConfig.Root.Add(new XElement("packageSourceMapping",
            new XElement("clear"),
            sources.Elements("add").Select(source =>
                new XElement("packageSource", new XAttribute("key", source.Attribute("key")!.Value),
                    new XElement("package", new XAttribute("pattern", "*"))))));
 
        return originalConfig;
    }
}