File: Projects\FallbackProjectParserTests.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.Text.Json;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Projects;
 
public class FallbackProjectParserTests(ITestOutputHelper output)
{
    private static readonly JsonSerializerOptions s_indentedOptions = new() { WriteIndented = true };
 
    private static string FormatJson(JsonDocument document)
    {
        return JsonSerializer.Serialize(document.RootElement, s_indentedOptions);
    }
 
    [Fact]
    public async Task ParseProject_ExtractsAspireAppHostSdk_OldFormat()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.csproj");
        var projectContent = """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0-test" />
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_ExtractsAspireAppHostSdk_NewFormat()
    {
        // Arrange - tests the new <Project Sdk="Aspire.AppHost.Sdk/version"> format
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.csproj");
        var projectContent = """
            <Project Sdk="Aspire.AppHost.Sdk/13.0.1">
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net10.0</TargetFramework>
                </PropertyGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_ExtractsAspireAppHostSdk_NewFormat_WithMultipleSdks()
    {
        // Arrange - tests parsing when multiple SDKs are in the attribute
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.csproj");
        var projectContent = """
            <Project Sdk="Aspire.AppHost.Sdk/13.0.1;Microsoft.NET.Sdk">
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                    <TargetFramework>net10.0</TargetFramework>
                </PropertyGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert - should extract only the version, not the other SDK
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_DoesNotMatchSimilarSdkName()
    {
        // Arrange - tests that Aspire.AppHost.SdkFoo doesn't match
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.csproj");
        var projectContent = """
            <Project Sdk="Aspire.AppHost.SdkFoo/1.0.0">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
                <PropertyGroup>
                    <OutputType>Exe</OutputType>
                </PropertyGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert - should fall back to old format and get 9.5.0
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_ExtractsPackageReferences()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.csproj");
        var projectContent = """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0-test" />
                <ItemGroup>
                    <PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.0-test" />
                    <PackageReference Include="Aspire.Hosting.Redis" Version="9.4.1" />
                </ItemGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_ExtractsProjectReferences()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.csproj");
        var projectContent = """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0-test" />
                <ItemGroup>
                    <ProjectReference Include="../ServiceDefaults/ServiceDefaults.csproj" />
                    <ProjectReference Include="../WebApp/WebApp.csproj" />
                </ItemGroup>
            </Project>
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert - scrub the temp path and normalize path separators for cross-platform consistency
        // Note: In JSON, backslashes are escaped as \\, so we need to replace \\\\ (which represents \\)
        // with / to get a single forward slash
        var tempPath = Path.GetTempPath().Replace("\\", "/");
        await Verify(FormatJson(result), extension: "json")
            .ScrubLinesWithReplace(line =>
            {
                // First normalize JSON-escaped backslashes (\\) to forward slashes
                // In JSON, a single backslash is represented as \\, so we replace that with /
                line = line.Replace("\\\\", "/");
                // Then replace the temp path
                line = line.Replace($"/private{tempPath}", "{TempPath}"); // Handle macOS temp symlinks
                line = line.Replace(tempPath, "{TempPath}");
                return line;
            });
    }
 
    [Fact]
    public async Task ParseProject_InvalidXml_ThrowsProjectUpdaterException()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.csproj");
        var invalidProjectContent = """
            <Project Sdk="Microsoft.NET.Sdk">
                <Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0-test" />
                <!-- Missing closing tag -->
                <ItemGroup>
                    <PackageReference Include="Test" Version="1.0.0" />
            """;
 
        await File.WriteAllTextAsync(projectFile, invalidProjectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act & Assert
        Assert.Throws<ProjectUpdaterException>(() =>
            parser.ParseProject(new FileInfo(projectFile)));
    }
 
    [Fact]
    public async Task ParseProject_SingleFileAppHost_ExtractsAspireAppHostSdk()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.cs");
        var projectContent = """
            #:sdk Aspire.AppHost.Sdk@13.0.0-preview.1.25519.5
            #:package Aspire.Hosting.NodeJs@9.5.1
 
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_SingleFileAppHost_ExtractsPackageReferences()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.cs");
        var projectContent = """
            #:sdk Aspire.AppHost.Sdk@13.0.0-preview.1.25519.5
            #:package Aspire.Hosting.NodeJs@9.5.1
            #:package Aspire.Hosting.Python@9.5.1
            #:package Aspire.Hosting.Redis@9.5.1
            #:package CommunityToolkit.Aspire.Hosting.NodeJS.Extensions@9.8.0
 
            #pragma warning disable ASPIREHOSTINGPYTHON001
 
            var builder = DistributedApplication.CreateBuilder(args);
            var cache = builder.AddRedis("cache");
            builder.Build().Run();
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_SingleFileAppHost_NoPackageReferences()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.cs");
        var projectContent = """
            #:sdk Aspire.AppHost.Sdk@9.5.0
 
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_SingleFileAppHost_WithWildcardVersion()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.cs");
        var projectContent = """
            #:sdk Aspire.AppHost.Sdk@*
            #:package Aspire.Hosting.Redis@*
 
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_SingleFileAppHost_NoProjectReferences()
    {
        // Arrange - single-file apphosts don't support project references
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.cs");
        var projectContent = """
            #:sdk Aspire.AppHost.Sdk@9.5.0
 
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_SingleFileAppHost_NoSdkDirective()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.cs");
        var projectContent = """
            // Missing SDK directive
            var builder = DistributedApplication.CreateBuilder(args);
            builder.Build().Run();
            """;
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act
        var result = parser.ParseProject(new FileInfo(projectFile));
 
        // Assert
        await Verify(FormatJson(result), extension: "json");
    }
 
    [Fact]
    public async Task ParseProject_UnsupportedFileType_ThrowsProjectUpdaterException()
    {
        // Arrange
        using var workspace = TemporaryWorkspace.Create(output);
        var projectFile = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.txt");
        var projectContent = "Some random content";
 
        await File.WriteAllTextAsync(projectFile, projectContent);
        var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
 
        // Act & Assert
        var exception = Assert.Throws<ProjectUpdaterException>(() =>
            parser.ParseProject(new FileInfo(projectFile)));
        Assert.Contains("Unsupported project file type", exception.Message);
    }
}