File: Projects\AppHostInfoResolverTests.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.Caching;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Microsoft.AspNetCore.InternalTesting;
 
namespace Aspire.Cli.Tests.Projects;
 
public sealed class AppHostInfoResolverTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task GetAppHostInfoAsync_UsesDiskCacheWhenPresent()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = CreateProjectFile(workspace);
        var runner = new TestDotNetCliRunner
        {
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) => throw new InvalidOperationException("MSBuild should not run on disk cache hit."),
        };
        var diskCache = new TestAppHostInfoDiskCache
        {
            Entry = new AppHostInfoCacheEntry
            {
                ExitCode = 0,
                IsAspireHost = true,
                AspireHostingVersion = "9.5.0",
                IsUsingCliBundle = true,
                UserSecretsId = "secrets",
                RunCommand = "/repo/bin/AppHost",
                TargetPath = "/repo/bin/AppHost.dll",
                RunWorkingDirectory = "/repo/src/AppHost",
                RunArguments = "--from-msbuild",
                TargetFramework = "net10.0",
                TargetFrameworks = null,
            },
        };
        var resolver = new AppHostInfoResolver(runner, diskCache);
 
        var info = await resolver.GetAppHostInfoAsync(projectFile, CancellationToken.None).DefaultTimeout();
 
        Assert.True(info.IsAspireHost);
        Assert.Equal("9.5.0", info.AspireHostingVersion);
        Assert.True(info.IsUsingCliBundle);
        Assert.Equal("secrets", info.UserSecretsId);
        Assert.Equal("/repo/bin/AppHost", info.RunCommand);
        Assert.Equal("/repo/bin/AppHost.dll", info.TargetPath);
        Assert.Equal("/repo/src/AppHost", info.RunWorkingDirectory);
        Assert.Equal("--from-msbuild", info.RunArguments);
        Assert.Equal("net10.0", info.TargetFramework);
    }
 
    [Fact]
    public async Task GetAppHostInfoAsync_CachesSuccessfulMsBuildEvaluationInMemory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = CreateProjectFile(workspace);
        var msbuildCalls = 0;
        var runner = new TestDotNetCliRunner
        {
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                msbuildCalls++;
                return (0, CreateAppHostInfoJson());
            },
        };
        var resolver = new AppHostInfoResolver(runner, new NullAppHostInfoDiskCache());
 
        var first = await resolver.GetAppHostInfoAsync(projectFile, CancellationToken.None).DefaultTimeout();
        var second = await resolver.GetAppHostInfoAsync(projectFile, CancellationToken.None).DefaultTimeout();
 
        Assert.True(first.IsAspireHost);
        Assert.True(second.IsAspireHost);
        Assert.Equal(1, msbuildCalls);
    }
 
    [Fact]
    public async Task GetAppHostInfoAsync_DoesNotCacheFailedMsBuildEvaluationInMemory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = CreateProjectFile(workspace);
        var msbuildCalls = 0;
        var runner = new TestDotNetCliRunner
        {
            GetProjectItemsAndPropertiesAsyncCallback = (_, _, _, _, _) =>
            {
                msbuildCalls++;
                return msbuildCalls == 1
                    ? (1, null)
                    : (0, CreateAppHostInfoJson());
            },
        };
        var resolver = new AppHostInfoResolver(runner, new NullAppHostInfoDiskCache());
 
        var failed = await resolver.GetAppHostInfoAsync(projectFile, CancellationToken.None).DefaultTimeout();
        var succeeded = await resolver.GetAppHostInfoAsync(projectFile, CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(1, failed.ExitCode);
        Assert.True(succeeded.IsAspireHost);
        Assert.Equal(2, msbuildCalls);
    }
 
    [Fact]
    public async Task GetAppHostInfoAsync_CoalescesConcurrentMsBuildEvaluationInMemory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = CreateProjectFile(workspace);
        var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var complete = new TaskCompletionSource<(int ExitCode, JsonDocument? Output)>(TaskCreationOptions.RunContinuationsAsynchronously);
        var msbuildCalls = 0;
        var runner = new TestDotNetCliRunner
        {
            GetProjectItemsAndPropertiesAsyncCallbackAsync = (_, _, _, _, _) =>
            {
                Interlocked.Increment(ref msbuildCalls);
                started.TrySetResult();
                return complete.Task;
            },
        };
        var resolver = new AppHostInfoResolver(runner, new NullAppHostInfoDiskCache());
 
        var tasks = Enumerable.Range(0, 10)
            .Select(_ => resolver.GetAppHostInfoAsync(projectFile, CancellationToken.None))
            .ToArray();
 
        await started.Task.DefaultTimeout();
        complete.SetResult((0, CreateAppHostInfoJson()));
        var results = await Task.WhenAll(tasks).DefaultTimeout();
 
        Assert.All(results, result => Assert.True(result.IsAspireHost));
        Assert.Equal(1, msbuildCalls);
    }
 
    [Fact]
    public async Task GetAppHostInfoAsync_CallerCancellationDoesNotCancelSharedEvaluation()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = CreateProjectFile(workspace);
        var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var complete = new TaskCompletionSource<(int ExitCode, JsonDocument? Output)>(TaskCreationOptions.RunContinuationsAsynchronously);
        var msbuildCalls = 0;
        var runner = new TestDotNetCliRunner
        {
            GetProjectItemsAndPropertiesAsyncCallbackAsync = (_, _, _, _, cancellationToken) =>
            {
                Assert.False(cancellationToken.CanBeCanceled);
                Interlocked.Increment(ref msbuildCalls);
                started.TrySetResult();
                return complete.Task;
            },
        };
        var resolver = new AppHostInfoResolver(runner, new NullAppHostInfoDiskCache());
 
        using var cancellationTokenSource = new CancellationTokenSource();
        var canceledWaiter = resolver.GetAppHostInfoAsync(projectFile, cancellationTokenSource.Token);
 
        await started.Task.DefaultTimeout();
        await cancellationTokenSource.CancelAsync();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await canceledWaiter).DefaultTimeout();
 
        complete.SetResult((0, CreateAppHostInfoJson()));
        var uncanceledWaiter = await resolver.GetAppHostInfoAsync(projectFile, CancellationToken.None).DefaultTimeout();
 
        Assert.True(uncanceledWaiter.IsAspireHost);
        Assert.Equal(1, msbuildCalls);
    }
 
    [Fact]
    public async Task GetAppHostInfoAsync_RequestsOnlyEvaluationSafeTarget()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = CreateProjectFile(workspace);
        string[]? capturedProperties = null;
        string[]? capturedTargets = null;
        var runner = new TestDotNetCliRunner
        {
            GetProjectItemsAndPropertiesAsyncCallbackWithTargets = (_, _, properties, targets, _, _) =>
            {
                capturedProperties = properties;
                capturedTargets = targets;
                return (0, CreateAppHostInfoJson());
            },
        };
        var resolver = new AppHostInfoResolver(runner, new NullAppHostInfoDiskCache());
 
        var info = await resolver.GetAppHostInfoAsync(projectFile, CancellationToken.None).DefaultTimeout();
 
        Assert.True(info.IsAspireHost);
        // The direct-launch path reads RunCommand/RunArguments/RunWorkingDirectory, which the
        // SDK only populates after ComputeRunArguments has run. The resolver must request that
        // target on its single MSBuild probe so the cached run metadata is correct.
        Assert.NotNull(capturedProperties);
        Assert.Equal(
            ["IsAspireHost", "AspireHostingSDKVersion", "AspireUseCliBundle", "UserSecretsId", "RunCommand", "TargetPath", "RunWorkingDirectory", "RunArguments", "TargetFramework", "TargetFrameworks"],
            capturedProperties);
        Assert.NotNull(capturedTargets);
        Assert.Equal(["ComputeRunArguments"], capturedTargets);
    }
 
    private static FileInfo CreateProjectFile(TemporaryWorkspace workspace)
    {
        // Write a realistic AppHost project so the XML pre-filter detects it from the project
        // content (the Aspire.AppHost.Sdk reference), not just from the "*.AppHost.csproj" file name.
        var path = Path.Combine(workspace.WorkspaceRoot.FullName, "Test.AppHost.csproj");
        File.WriteAllText(path, "<Project Sdk=\"Microsoft.NET.Sdk;Aspire.AppHost.Sdk/9.5.0\" />");
        return new FileInfo(path);
    }
 
    private static JsonDocument CreateAppHostInfoJson()
    {
        return JsonDocument.Parse("""
            {
              "Properties": {
                "IsAspireHost": "true",
                "AspireHostingSDKVersion": "9.5.0",
                "AspireUseCliBundle": "true",
                "UserSecretsId": "secrets",
                "RunCommand": "/repo/bin/AppHost",
                "TargetPath": "/repo/bin/AppHost.dll",
                "RunWorkingDirectory": "/repo/src/AppHost",
                "RunArguments": "--from-msbuild",
                "TargetFramework": "net10.0"
              },
              "Items": {}
            }
            """);
    }
 
    private sealed class TestAppHostInfoDiskCache : IAppHostInfoDiskCache
    {
        public AppHostInfoCacheEntry? Entry { get; init; }
 
        public string GetCacheKey(FileInfo projectFile)
            => "test-key";
 
        public Task<AppHostInfoCacheEntry?> TryGetAsync(FileInfo projectFile, CancellationToken cancellationToken)
            => Task.FromResult(Entry);
 
        public Task SetAsync(FileInfo projectFile, string expectedCacheKey, AppHostInfoCacheEntry entry, CancellationToken cancellationToken)
            => Task.CompletedTask;
    }
}