File: TemplateManifest\TemplateRestorePlanTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.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.Text.Json.Nodes;
using System.Text.Json;
using System.Xml.Linq;
using Aspire.TestUtilities;
using GenerateTemplateManifest;
using Xunit;
 
namespace Infrastructure.Tests.TemplateManifest;
 
public sealed class TemplateRestorePlanTests(ITestOutputHelper output)
{
    [Fact]
    public async Task DeduplicatesTemplatesAndFrameworksWithoutCombiningPackageChoices()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = workspace.CreateDirectory("source").FullName;
        const string content = """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
              <ItemGroup>
                <PackageReference Include="Common" Version="1.0.0" />
                <!--#if (UseRedis) -->
                <PackageReference Include="Optional" Version="2.0.0" />
                <!--#endif -->
              </ItemGroup>
            </Project>
            """;
        WriteTemplate(source, "first", content);
        WriteTemplate(source, "second", content);
 
        var plan = new TemplateRestorePlan();
        await plan.DiscoverAsync(source, source, workspace.CreateDirectory("work").FullName);
 
        Assert.Equal(8, plan.ConfigurationCount);
        Assert.Equal(8, plan.ProjectCount);
        Assert.Equal(2, plan.Projects.Count);
        foreach (var project in plan.Projects)
        {
            Assert.Equal(["net10.0", "net8.0"], project.Frameworks);
        }
        Assert.Equal(
            [1, 2],
            plan.Projects.Select(p => p.Project.Descendants("PackageReference").Count()).Order());
    }
 
    [Fact]
    public async Task PreservesComputedSymbolsSourceExclusionsAndConflictingVersions()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = workspace.CreateDirectory("source").FullName;
        WriteTemplate(source, "choices", """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
              <ItemGroup>
                <!--#if (IsNew) -->
                <PackageReference Include="Shared" Version="2.0.0" />
                <!--#else -->
                <PackageReference Include="Shared" Version="1.0.0" />
                <!--#endif -->
              </ItemGroup>
            </Project>
            """, """
            {
              "type": "parameter", "datatype": "choice", "defaultValue": "Old",
              "choices": [{"choice":"None"}, {"choice":"Old"}, {"choice":"New"}]
            }
            """);
        var configPath = Path.Combine(source, "choices", ".template.config", "template.json");
        var config = JsonNode.Parse(File.ReadAllText(configPath))!;
        config["symbols"]!["IsNew"] = JsonNode.Parse("""{"type":"computed","value":"(Mode == \"New\")"}""");
        config["sources"] = JsonNode.Parse("""[{"modifiers":[{"condition":"(Mode == \"None\")","exclude":["*.csproj"]}]}]""");
        File.WriteAllText(configPath, config.ToJsonString());
 
        var plan = new TemplateRestorePlan();
        await plan.DiscoverAsync(source, source, workspace.CreateDirectory("work").FullName);
 
        Assert.Equal(6, plan.ConfigurationCount);
        Assert.Equal(4, plan.ProjectCount);
        Assert.Equal(2, plan.Projects.Count);
        Assert.Equal(["1.0.0", "2.0.0"], plan.Projects
            .Select(p => p.Project.Descendants("PackageReference").Single().Attribute("Version")!.Value).Order());
    }
 
    [Fact]
    public async Task PreservesProjectEdgesAndDependencyMetadata()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = workspace.CreateDirectory("source").FullName;
        WriteTemplate(source, "edges", """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup><TargetFramework>net8.0</TargetFramework><UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner></PropertyGroup>
              <ItemGroup>
                <FrameworkReference Include="Microsoft.AspNetCore.App" />
                <ProjectReference Include="Child.csproj" />
                <PackageReference Include="Root" Version="1.0.0" PrivateAssets="all" />
              </ItemGroup>
            </Project>
            """);
        File.WriteAllText(Path.Combine(source, "edges", "Child.csproj"), """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
              <ItemGroup><PackageReference Include="Child" Version="1.0.0" /></ItemGroup>
            </Project>
            """);
 
        var plan = new TemplateRestorePlan();
        await plan.DiscoverAsync(source, source, workspace.CreateDirectory("work").FullName);
 
        Assert.Equal(2, plan.Projects.Count);
        var parent = Assert.Single(plan.Projects, p => p.Project.Descendants("ProjectReference").Any());
        var child = Assert.Single(plan.Projects, p => !p.Project.Descendants("ProjectReference").Any());
        Assert.Equal($"../{child.Name}/{child.Name}.csproj", parent.Project.Descendants("ProjectReference").Single().Attribute("Include")!.Value);
        Assert.Equal("true", parent.Project.Descendants("UseMicrosoftTestingPlatformRunner").Single().Value);
        Assert.Equal("all", parent.Project.Descendants("PackageReference").Single().Attribute("PrivateAssets")!.Value);
        Assert.Equal("Microsoft.AspNetCore.App", parent.Project.Descendants("FrameworkReference").Single().Attribute("Include")!.Value);
    }
 
    [Fact]
    public void DoesNotReorderConditionalOrOverriddenProperties()
    {
        var project = XElement.Parse("""
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup><Mode>first</Mode></PropertyGroup>
              <PropertyGroup Condition="'$(Mode)' == 'first'"><Mode>second</Mode></PropertyGroup>
            </Project>
            """);
 
        var normalized = TemplateRestorePlan.Normalize(project);
 
        Assert.Equal(["first", "second"], normalized.Descendants("Mode").Select(e => e.Value));
        Assert.Equal("'$(Mode)' == 'first'", normalized.Elements("PropertyGroup").Last().Attribute("Condition")!.Value);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    [RequiresTools(["pwsh"])]
    public async Task PreservesItemDeclarationOrderDuringRestore(bool separateGroups)
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var environment = new TemplateManifestTestEnvironment(workspace, output);
        TemplateManifestTestEnvironment.CreatePackage(environment.ExternalFeed, "External.ItemOrderFixture", "1.0.0");
        var source = XElement.Parse($"""
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup><TargetFramework>net11.0</TargetFramework></PropertyGroup>
              <ItemGroup>
                <TemplatePackage Include="External.ItemOrderFixture" Version="1.0.0" />
                {(separateGroups ? "</ItemGroup><ItemGroup>" : "")}
                <PackageReference Include="@(TemplatePackage)" />
              </ItemGroup>
            </Project>
            """);
 
        foreach (var normalize in new[] { false, true })
        {
            var directory = workspace.CreateDirectory(normalize ? "normalized" : "original").FullName;
            var path = Path.Combine(directory, "App.csproj");
            new XDocument(normalize ? TemplateRestorePlan.Normalize(source) : source).Save(path);
            using var command = environment.CreateRestoreCommand(path);
 
            var result = await command.ExecuteAsync();
            result.EnsureSuccessful();
 
            using var assets = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory, "obj", "project.assets.json")));
            Assert.Equal(["External.ItemOrderFixture/1.0.0"],
                assets.RootElement.GetProperty("libraries").EnumerateObject().Select(p => p.Name));
            using var evaluation = JsonDocument.Parse(result.Output);
            var package = Assert.Single(evaluation.RootElement.GetProperty("Items").GetProperty("PackageReference").EnumerateArray());
            Assert.Equal("External.ItemOrderFixture", package.GetProperty("Identity").GetString());
            Assert.Equal("1.0.0", package.GetProperty("Version").GetString());
        }
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task DoesNotMovePropertiesPastAChooseThatSelectsDependencies()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = XElement.Parse("""
            <Project>
              <PropertyGroup><UsePrimary>true</UsePrimary></PropertyGroup>
              <Choose>
                <When Condition="'$(UsePrimary)' == 'true'">
                  <ItemGroup><PackageReference Include="Primary" Version="1.0.0" /></ItemGroup>
                </When>
                <Otherwise>
                  <ItemGroup><PackageReference Include="WrongBranch" Version="2.0.0" /></ItemGroup>
                </Otherwise>
              </Choose>
              <PropertyGroup><OtherProperty>value</OtherProperty></PropertyGroup>
            </Project>
            """);
        var path = Path.Combine(workspace.Path, "normalized.proj");
        new XDocument(TemplateRestorePlan.Normalize(source)).Save(path);
        var script = Path.Combine(workspace.Path, "evaluate.ps1");
        File.WriteAllText(script, """
            & $env:TEST_DOTNET msbuild $env:TEST_PROJECT -nologo -getItem:PackageReference
            exit $LASTEXITCODE
            """);
        using var command = new PowerShellCommand(script, output)
            .WithTimeout(TimeSpan.FromMinutes(1))
            .WithEnvironmentVariable("TEST_DOTNET", Path.Combine(RepoRoot.Path, ".dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"))
            .WithEnvironmentVariable("TEST_PROJECT", path);
 
        var result = await command.ExecuteAsync();
        result.EnsureSuccessful();
 
        using var evaluation = JsonDocument.Parse(result.Output);
        var package = Assert.Single(evaluation.RootElement.GetProperty("Items").GetProperty("PackageReference").EnumerateArray());
        Assert.Equal("Primary", package.GetProperty("Identity").GetString());
        Assert.Equal("1.0.0", package.GetProperty("Version").GetString());
    }
 
    [Theory]
    [InlineData("<Import Project=\"external.props\" />")]
    [InlineData("<Target Name=\"Collect\"><Message Text=\"$(Property)\" /></Target>")]
    public void PreservesGroupsAroundOtherTopLevelElements(string element)
    {
        var source = XElement.Parse($"""
            <Project>
              <PropertyGroup><Property>before</Property></PropertyGroup>
              {element}
              <ItemGroup><PackageReference Include="Example" Version="1.0.0" /></ItemGroup>
            </Project>
            """);
 
        Assert.True(XNode.DeepEquals(source, TemplateRestorePlan.Normalize(source)));
    }
 
    [Fact]
    public void RejectsUnboundedDependencyParameters()
    {
        var config = JsonNode.Parse("""
            {"symbols":{"PackageVersion":{"type":"parameter","datatype":"string","defaultValue":"1.0.0"}}}
            """)!.AsObject();
 
        Assert.Throws<InvalidDataException>(() => TemplateRestorePlan.GetConfigurations(config, "$(PackageVersion)").ToArray());
    }
 
    [Fact]
    public async Task PreservesFileBasedSdkAndPropertiesAcrossFrameworks()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = workspace.CreateDirectory("source").FullName;
        WriteTemplate(source, "single", "<Project />");
        File.Delete(Path.Combine(source, "single", "App.csproj"));
        File.WriteAllText(Path.Combine(source, "single", "apphost.cs"), """
            #:sdk Aspire.AppHost.Sdk@13.5.4
            #:property AspireUseCliBundle=true
            #:package Additional@1.0.0
            """);
 
        var plan = new TemplateRestorePlan();
        await plan.DiscoverAsync(source, source, workspace.CreateDirectory("work").FullName);
 
        var project = Assert.Single(plan.Projects);
        Assert.Equal(["net10.0", "net8.0"], project.Frameworks);
        Assert.Equal(("Aspire.AppHost.Sdk", "13.5.4"), Assert.Single(plan.Sdks));
        Assert.Equal("true", project.Project.Descendants("AspireUseCliBundle").Single().Value);
        Assert.Equal("true", project.Project.Descendants("FileBasedProgram").Single().Value);
        Assert.Equal("Additional", project.Project.Descendants("PackageReference").Single().Attribute("Include")!.Value);
    }
 
    [Fact]
    public async Task RejectsMissingProjectReferences()
    {
        using var workspace = TemporaryWorkspace.Create(output);
        var source = workspace.CreateDirectory("source").FullName;
        WriteTemplate(source, "missing", """
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
              <ItemGroup><ProjectReference Include="Missing.csproj" /></ItemGroup>
            </Project>
            """);
 
        var plan = new TemplateRestorePlan();
        await Assert.ThrowsAsync<InvalidDataException>(() => plan.DiscoverAsync(source, source, workspace.CreateDirectory("work").FullName));
    }
 
    private static void WriteTemplate(string source, string identity, string project, string? mode = null)
    {
        var directory = Path.Combine(source, identity);
        Directory.CreateDirectory(Path.Combine(directory, ".template.config"));
        File.WriteAllText(Path.Combine(directory, "App.csproj"), project);
        var config = JsonNode.Parse("""
            {
              "identity": "", "name": "", "shortName": "", "symbols": {
                "Framework": {"type":"parameter","datatype":"choice","replaces":"net8.0","defaultValue":"net8.0","choices":[{"choice":"net8.0"},{"choice":"net10.0"}]},
                "UseRedis": {"type":"parameter","datatype":"bool","defaultValue":"false"},
                "Port": {"type":"parameter","datatype":"integer","defaultValue":"1234"}
              }
            }
            """)!;
        config["identity"] = identity;
        config["name"] = identity;
        config["shortName"] = identity;
        if (mode is not null)
        {
            config["symbols"]!["Mode"] = JsonNode.Parse(mode);
        }
        File.WriteAllText(Path.Combine(directory, ".template.config", "template.json"), config.ToJsonString());
    }
}