File: DotnetProjectPathTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Dotnet.Tests\Aspire.Hosting.Dotnet.Tests.csproj (Aspire.Hosting.Dotnet.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
 
namespace Aspire.Hosting.Dotnet.Tests;
 
public class DotnetProjectPathTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [InlineData(".csproj", false, "CodeCsRectangle")]
    [InlineData(".fsproj", false, "CodeFsRectangle")]
    [InlineData(".vbproj", false, "CodeVbRectangle")]
    [InlineData(".CSPROJ", false, "CodeCsRectangle")]
    [InlineData(".FSPROJ", false, "CodeFsRectangle")]
    [InlineData(".VBPROJ", false, "CodeVbRectangle")]
    [InlineData(".cs", true, "CodeCsRectangle")]
    [InlineData(".CS", true, "CodeCsRectangle")]
    public async Task ExplicitSupportedFilesRetainTheirProjectOrFileAppCategory(string extension, bool fileBased, string iconName)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var projectPath = Path.Combine(workspace.Path, $"Service{extension}");
        File.WriteAllText(projectPath, "<Project />");
        using var builder = TestDistributedApplicationBuilder.Create(
            options => options.ProjectDirectory = workspace.Path,
            outputHelper);
        var resource = builder.AddDotnetProject("service", projectPath, options => options.ExcludeLaunchProfile = true);
        var relativeResource = builder.AddDotnetProject("relative", Path.GetFileName(projectPath), options => options.ExcludeLaunchProfile = true);
 
        var metadata = resource.Resource.GetProjectMetadata();
        Assert.Equal(projectPath, metadata.ProjectPath);
        Assert.Equal(projectPath, relativeResource.Resource.GetProjectMetadata().ProjectPath);
        Assert.Equal(fileBased, metadata.IsFileBasedApp);
        Assert.True(metadata.SuppressBuild);
        var arguments = await ArgumentEvaluator.GetArgumentListAsync(resource.Resource);
        Assert.Equal(["run", fileBased ? "--file" : "--project", projectPath, "--no-build"], arguments.Take(4));
        Assert.Equal(workspace.Path, resource.Resource.WorkingDirectory);
        Assert.Single(builder.Resources.OfType<DotnetProjectBuildResource>());
        Assert.Equal(iconName, Assert.Single(resource.Resource.Annotations.OfType<ResourceIconAnnotation>()).IconName);
        Assert.Equal(iconName, Assert.Single(relativeResource.Resource.Annotations.OfType<ResourceIconAnnotation>()).IconName);
    }
 
    [Theory]
    [InlineData(".csproj", "CodeCsRectangle")]
    [InlineData(".fsproj", "CodeFsRectangle")]
    [InlineData(".vbproj", "CodeVbRectangle")]
    [InlineData(".CSPROJ", "CodeCsRectangle")]
    [InlineData(".FSPROJ", "CodeFsRectangle")]
    [InlineData(".VBPROJ", "CodeVbRectangle")]
    public void DirectoryShorthandResolvesExactlyOneSupportedProjectAcrossLanguages(string extension, string iconName)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var directory = workspace.CreateDirectory("Service");
        var projectPath = Path.Combine(directory.FullName, $"Service{extension}");
        File.WriteAllText(projectPath, "<Project />");
        File.WriteAllText(Path.Combine(directory.FullName, "app.cs"), "");
        File.WriteAllText(Path.Combine(directory.FullName, "script.fsx"), "");
        var nested = directory.CreateSubdirectory("nested");
        File.WriteAllText(Path.Combine(nested.FullName, "Other.csproj"), "<Project />");
        using var builder = TestDistributedApplicationBuilder.Create(
            options => options.ProjectDirectory = workspace.Path,
            outputHelper);
 
        var absolute = builder.AddDotnetProject("absolute", directory.FullName, options => options.ExcludeLaunchProfile = true);
        var relative = builder.AddDotnetProject("relative", "Service", options => options.ExcludeLaunchProfile = true);
 
        Assert.Equal(projectPath, absolute.Resource.GetProjectMetadata().ProjectPath);
        Assert.Equal(projectPath, relative.Resource.GetProjectMetadata().ProjectPath);
        Assert.Equal(directory.FullName, absolute.Resource.WorkingDirectory);
        Assert.Equal(directory.FullName, relative.Resource.WorkingDirectory);
        Assert.False(absolute.Resource.GetProjectMetadata().IsFileBasedApp);
        Assert.Single(Assert.Single(builder.Resources.OfType<DotnetProjectBuildResource>()).ProjectPaths);
        Assert.Equal(iconName, Assert.Single(absolute.Resource.Annotations.OfType<ResourceIconAnnotation>()).IconName);
        Assert.Equal(iconName, Assert.Single(relative.Resource.Annotations.OfType<ResourceIconAnnotation>()).IconName);
 
        File.WriteAllText(Path.Combine(directory.FullName, "Later.vbproj"), "<Project />");
        Assert.Equal(projectPath, absolute.Resource.GetProjectMetadata().ProjectPath);
        Assert.Equal(projectPath, relative.Resource.GetProjectMetadata().ProjectPath);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void EmptyPathUsesTheAppHostDirectoryWithoutEagerlyRejectingUnresolvablePaths(bool includeProject)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var path = Path.Combine(workspace.Path, "Service.fsproj");
        if (includeProject)
        {
            File.WriteAllText(path, "<Project />");
        }
        using var builder = TestDistributedApplicationBuilder.Create(
            options => options.ProjectDirectory = workspace.Path,
            outputHelper);
 
        var resource = builder.AddDotnetProject("service", "", options => options.ExcludeLaunchProfile = true);
 
        Assert.Equal(
            includeProject ? path : workspace.Path,
            Path.TrimEndingDirectorySeparator(resource.Resource.GetProjectMetadata().ProjectPath));
    }
 
    [Theory]
    [InlineData(".csproj", ".csproj")]
    [InlineData(".csproj", ".fsproj")]
    [InlineData(".csproj", ".vbproj")]
    [InlineData(".fsproj", ".vbproj")]
    [InlineData(".fsproj", ".fsproj")]
    [InlineData(".vbproj", ".vbproj")]
    [InlineData(".FSPROJ", ".VBPROJ")]
    [InlineData(null, null)]
    public async Task UnresolvableDirectoriesFailAtResourceStartAndRequireAnExplicitFilename(
        string? firstExtension,
        string? secondExtension)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var directory = workspace.CreateDirectory("Service");
        if (firstExtension is not null)
        {
            File.WriteAllText(Path.Combine(directory.FullName, $"First{firstExtension}"), "<Project />");
            File.WriteAllText(Path.Combine(directory.FullName, $"Second{secondExtension}"), "<Project />");
        }
        File.WriteAllText(Path.Combine(directory.FullName, "app.cs"), "");
        using var builder = TestDistributedApplicationBuilder.Create(
            options => options.ProjectDirectory = workspace.Path,
            outputHelper);
 
        var resource = builder.AddDotnetProject("service", directory.FullName, options => options.ExcludeLaunchProfile = true);
 
        Assert.Equal(directory.FullName, resource.Resource.GetProjectMetadata().ProjectPath);
        Assert.False(resource.Resource.GetProjectMetadata().SuppressBuild);
        Assert.Empty(builder.Resources.OfType<DotnetProjectBuildResource>());
        using var app = builder.Build();
        await EventingTestHelpers.SubscribeEventingSubscribersAsync(app, TestContext.Current.CancellationToken);
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(() =>
            builder.Eventing.PublishAsync(
                new BeforeResourceStartedEvent(resource.Resource, app.Services),
                TestContext.Current.CancellationToken));
 
        Assert.Equal(
            $"Path to .NET project for resource 'service' could not be determined. The directory '{directory.FullName}' must contain exactly one .csproj, .fsproj, or .vbproj file. Specify an explicit project filename when multiple supported projects exist.",
            exception.Message);
    }
 
    [Theory]
    [InlineData("Service.fs")]
    [InlineData("Service.fsx")]
    [InlineData("Service.vb")]
    [InlineData("Service.vcxproj")]
    [InlineData("Service.proj")]
    [InlineData("MissingDirectory")]
    public async Task UnsupportedAndMissingPathsFailAtResourceStart(string fileName)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var path = Path.Combine(workspace.Path, fileName);
        if (Path.HasExtension(fileName))
        {
            File.WriteAllText(path, "");
        }
        using var builder = TestDistributedApplicationBuilder.Create(
            options => options.ProjectDirectory = workspace.Path,
            outputHelper);
        var resource = builder.AddDotnetProject("service", path, options => options.ExcludeLaunchProfile = true);
        using var app = builder.Build();
        await EventingTestHelpers.SubscribeEventingSubscribersAsync(app, TestContext.Current.CancellationToken);
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(() =>
            builder.Eventing.PublishAsync(
                new BeforeResourceStartedEvent(resource.Resource, app.Services),
                TestContext.Current.CancellationToken));
 
        Assert.Equal(
            $"The .NET app path '{path}' for resource 'service' is invalid. The path must be to a .cs file, an SDK-style .csproj, .fsproj, or .vbproj file, or a directory containing exactly one supported project.",
            exception.Message);
        Assert.Empty(builder.Resources.OfType<DotnetProjectBuildResource>());
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void LegacyDirectoryResolutionRemainsCSharpOnly(bool includeCSharpProject)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var directory = workspace.CreateDirectory("Service");
        var csharpPath = Path.Combine(directory.FullName, "Service.csproj");
        if (includeCSharpProject)
        {
            File.WriteAllText(csharpPath, "<Project />");
        }
        File.WriteAllText(Path.Combine(directory.FullName, "Service.fsproj"), "<Project />");
        File.WriteAllText(Path.Combine(directory.FullName, "Service.vbproj"), "<Project />");
        using var builder = TestDistributedApplicationBuilder.Create(
            options => options.ProjectDirectory = workspace.Path,
            outputHelper);
 
#pragma warning disable CS0618 // The legacy default resolver must not adopt V2's broader directory policy.
        var legacy = builder.AddProject("legacy", directory.FullName, options => options.ExcludeLaunchProfile = true);
        var legacyFileApp = builder.AddCSharpApp("legacy-file", directory.FullName, options => options.ExcludeLaunchProfile = true);
#pragma warning restore CS0618
        var modern = builder.AddDotnetProject("modern", directory.FullName, options => options.ExcludeLaunchProfile = true);
 
        Assert.Equal(includeCSharpProject ? csharpPath : directory.FullName, legacy.Resource.GetProjectMetadata().ProjectPath);
        Assert.Equal(includeCSharpProject ? csharpPath : directory.FullName, legacyFileApp.Resource.GetProjectMetadata().ProjectPath);
        Assert.Equal(directory.FullName, modern.Resource.GetProjectMetadata().ProjectPath);
    }
 
    [Theory]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    public void LegacyDirectoryResolutionDoesNotSelectASingleNonCSharpProject(string extension)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var path = Path.Combine(workspace.Path, $"Service{extension}");
        File.WriteAllText(path, "<Project />");
        using var builder = TestDistributedApplicationBuilder.Create();
 
#pragma warning disable CS0618 // The legacy default resolver remains C#-only even when V2 has a unique candidate.
        var legacy = builder.AddProject("legacy", workspace.Path, options => options.ExcludeLaunchProfile = true);
#pragma warning restore CS0618
        var modern = builder.AddDotnetProject("modern", workspace.Path, options => options.ExcludeLaunchProfile = true);
 
        Assert.Equal(workspace.Path, legacy.Resource.GetProjectMetadata().ProjectPath);
        Assert.Equal(path, modern.Resource.GetProjectMetadata().ProjectPath);
    }
 
    [Theory]
    [InlineData(".csproj")]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    [InlineData(".cs")]
    public void ExplicitIconOverridesTheLanguageDefault(string extension)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var path = Path.Combine(workspace.Path, $"Service{extension}");
        File.WriteAllText(path, "");
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var resource = builder.AddDotnetProject("service", path)
            .WithIconName("CodeCircle", IconVariant.Regular);
 
        var icon = Assert.Single(resource.Resource.Annotations.OfType<ResourceIconAnnotation>());
        Assert.Equal("CodeCircle", icon.IconName);
        Assert.Equal(IconVariant.Regular, icon.IconVariant);
    }
 
    [Theory]
    [InlineData(null, null)]
    [InlineData(".csproj", ".csproj")]
    [InlineData(".csproj", ".fsproj")]
    [InlineData(".csproj", ".vbproj")]
    [InlineData(".fsproj", ".vbproj")]
    [InlineData(".FSPROJ", ".VBPROJ")]
    public void UnresolvableDirectoriesUseActionableErrorsWithDefaultOptions(
        string? firstExtension,
        string? secondExtension)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var directory = workspace.CreateDirectory("Service");
        if (firstExtension is not null)
        {
            File.WriteAllText(Path.Combine(directory.FullName, $"First{firstExtension}"), "<Project />");
            File.WriteAllText(Path.Combine(directory.FullName, $"Second{secondExtension}"), "<Project />");
        }
        File.WriteAllText(Path.Combine(directory.FullName, "app.cs"), "");
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var exception = Assert.Throws<DistributedApplicationException>(() =>
            builder.AddDotnetProject("service", directory.FullName));
 
        Assert.Equal(
            $"Path to .NET project for resource 'service' could not be determined. The directory '{directory.FullName}' must contain exactly one .csproj, .fsproj, or .vbproj file. Specify an explicit project filename when multiple supported projects exist.",
            exception.Message);
    }
 
    [Theory]
    [InlineData("Service.fs")]
    [InlineData("Service.fsx")]
    [InlineData("Service.vb")]
    [InlineData("Service.vcxproj")]
    [InlineData("Service.proj")]
    public async Task ExistingUnsupportedFilesStillFailAtResourceStartWithDefaultOptions(string fileName)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var path = Path.Combine(workspace.Path, fileName);
        File.WriteAllText(path, "");
        using var builder = TestDistributedApplicationBuilder.Create();
        var resource = builder.AddDotnetProject("service", path);
        using var app = builder.Build();
        await EventingTestHelpers.SubscribeEventingSubscribersAsync(app, TestContext.Current.CancellationToken);
 
        var exception = await Assert.ThrowsAsync<DistributedApplicationException>(() =>
            builder.Eventing.PublishAsync(
                new BeforeResourceStartedEvent(resource.Resource, app.Services),
                TestContext.Current.CancellationToken));
 
        Assert.Equal(
            $"The .NET app path '{path}' for resource 'service' is invalid. The path must be to a .cs file, an SDK-style .csproj, .fsproj, or .vbproj file, or a directory containing exactly one supported project.",
            exception.Message);
    }
 
    [Fact]
    public void MissingDirectoryUsesActionableErrorWithDefaultOptions()
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var path = Path.Combine(workspace.Path, "MissingDirectory");
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var exception = Assert.Throws<DistributedApplicationException>(() =>
            builder.AddDotnetProject("service", path));
 
        Assert.Equal(
            $"The .NET app path '{path}' for resource 'service' is invalid. The path must be to a .cs file, an SDK-style .csproj, .fsproj, or .vbproj file, or a directory containing exactly one supported project.",
            exception.Message);
    }
 
    [Theory]
    [InlineData(".csproj")]
    [InlineData(".fsproj")]
    [InlineData(".vbproj")]
    [InlineData(".cs")]
    public void MissingSupportedFilesKeepTheirExistingValidationBoundary(string extension)
    {
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var path = Path.Combine(workspace.Path, $"Missing{extension}");
        using var builder = TestDistributedApplicationBuilder.Create();
 
        var exception = Assert.Throws<DistributedApplicationException>(() =>
            builder.AddDotnetProject("default", path));
 
        Assert.Equal($"Project file '{path}' was not found.", exception.Message);
        var excluded = builder.AddDotnetProject("excluded", path, options => options.ExcludeLaunchProfile = true);
        Assert.Equal(path, excluded.Resource.GetProjectMetadata().ProjectPath);
    }
}