// 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 Microsoft.Extensions.Logging.Abstractions;
namespace Aspire.Cli.Tests.Projects;
public class FallbackProjectParserTests(ITestOutputHelper output)
{
[Theory]
[InlineData("// #:package Aspire.Hosting.AppHost@17.0.0", false)]
[InlineData("var example = \"#:package Aspire.Hosting.AppHost@17.0.0\";", false)]
[InlineData("/*\n#:package Aspire.Hosting.AppHost@17.0.0\n*/", false)]
[InlineData("var example = \"\"\"\n#:package Aspire.Hosting.AppHost@17.0.0\n\"\"\";", false)]
[InlineData("var example = @\"\n#:package Aspire.Hosting.AppHost@17.0.0\n\";", false)]
[InlineData("/* example */\n#:package Aspire.Hosting.AppHost@17.0.0", true)]
[InlineData("#:package Aspire.Hosting.AppHost@17.0.0 unexpected", false)]
[InlineData(" #:package Aspire.Hosting.AppHost@17.0.0+build.1 \r\n", true)]
public void ParseProject_OnlyUsesCompletePackageDirectiveLines(string content, bool expected)
{
using var workspace = TemporaryWorkspace.CreateForCli(output);
var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"));
File.WriteAllText(projectFile.FullName, content);
var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
using var result = parser.ParseProject(projectFile);
Assert.Equal(expected, FallbackProjectParser.HasAppHostPackageDirective(content));
Assert.Equal(expected ? 1 : 0, result.RootElement.GetProperty("Items").GetProperty("PackageReference").GetArrayLength());
}
[Theory]
[InlineData("// #:sdk Aspire.AppHost.Sdk@17.0.0", false)]
[InlineData("var example = \"#:sdk Aspire.AppHost.Sdk@17.0.0\";", false)]
[InlineData("/*\n#:sdk Aspire.AppHost.Sdk@17.0.0\n*/", false)]
[InlineData("var example = \"\"\"\n#:sdk Aspire.AppHost.Sdk@17.0.0\n\"\"\";", false)]
[InlineData("#:sdk Aspire.AppHost.Sdk.Extra@17.0.0", false)]
[InlineData("#:sdk Aspire.AppHost.Sdk@17.0.0 unexpected", false)]
[InlineData(" #:sdk Aspire.AppHost.Sdk@17.0.0+build.1 \r\n", true)]
[InlineData("#:sdk Aspire.AppHost.Sdk", true)]
public void ParseProject_OnlyUsesCompleteSdkDirectiveLines(string content, bool expected)
{
using var workspace = TemporaryWorkspace.CreateForCli(output);
var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs"));
File.WriteAllText(projectFile.FullName, content);
var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
using var result = parser.ParseProject(projectFile);
Assert.Equal(expected ? "true" : "false", result.RootElement.GetProperty("Properties").GetProperty("_UsingAspireAppHostSdk").GetString());
}
[Theory]
[InlineData(".csproj")]
[InlineData(".fsproj")]
[InlineData(".vbproj")]
public void ParseProject_PackageMetadataUpdatesDoNotEstablishReferences(string extension)
{
using var workspace = TemporaryWorkspace.CreateForCli(output);
var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, $"AppHost{extension}"));
File.WriteAllText(projectFile.FullName, """
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Update="Aspire.Hosting.AppHost" Version="17.0.0" />
<PackageReference Include="Aspire.Hosting.Redis" Version="17.0.0" />
</ItemGroup>
</Project>
""");
var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
using var result = parser.ParseProject(projectFile);
var package = Assert.Single(result.RootElement.GetProperty("Items").GetProperty("PackageReference").EnumerateArray());
Assert.Equal("Aspire.Hosting.Redis", package.GetProperty("Identity").GetString());
Assert.Equal("17.0.0", package.GetProperty("Version").GetString());
}
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.CreateForCli(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.CreateForCli(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.CreateForCli(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.CreateForCli(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.CreateForCli(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.CreateForCli(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.CreateForCli(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)));
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task ParseProject_ProjectReferences_ResolvesEitherDirectorySeparator(bool absolute, bool backslashes)
{
using var workspace = TemporaryWorkspace.CreateForCli(output);
var appHostDirectory = workspace.CreateDirectory("AppHost");
var webDirectory = workspace.CreateDirectory("Web");
var referencedPath = Path.Combine(webDirectory.FullName, "Web.csproj");
var include = absolute ? referencedPath : Path.GetRelativePath(appHostDirectory.FullName, referencedPath);
include = backslashes ? include.Replace('/', '\\') : include.Replace('\\', '/');
var projectFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
await File.WriteAllTextAsync(projectFile.FullName, $$"""
<Project Sdk="Aspire.AppHost.Sdk/99.0.0-unresolvable">
<ItemGroup>
<ProjectReference Include="{{include}}" />
</ItemGroup>
</Project>
""");
var parser = new FallbackProjectParser(NullLogger<FallbackProjectParser>.Instance);
using var result = parser.ParseProject(projectFile);
var reference = Assert.Single(result.RootElement.GetProperty("Items").GetProperty("ProjectReference").EnumerateArray());
Assert.Equal(include, reference.GetProperty("Identity").GetString());
Assert.Equal(referencedPath, reference.GetProperty("FullPath").GetString());
}
[Fact]
public async Task ParseProject_SingleFileAppHost_ExtractsAspireAppHostSdk()
{
// Arrange
using var workspace = TemporaryWorkspace.CreateForCli(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.CreateForCli(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.CreateForCli(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.CreateForCli(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.CreateForCli(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.CreateForCli(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.CreateForCli(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);
}
}