File: Mcp\SelectAppHostToolTests.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.Backchannel;
using Aspire.Cli.Mcp.Tools;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
 
namespace Aspire.Cli.Tests.Mcp;
 
public class SelectAppHostToolTests(ITestOutputHelper outputHelper)
{
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task CallToolAsync_PreservesSuppliedPathInResponse(bool hasMatchingConnection)
    {
        Assert.SkipWhen(OperatingSystem.IsWindows(),
            "Symlink path spelling test only runs on Unix-like platforms.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real");
        var appHostFile = new FileInfo(Path.Combine(realDirectory.FullName, "AppHost.csproj"));
        File.WriteAllText(appHostFile.FullName, "<Project />");
 
        var symlinkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link");
        TestSymlinkHelper.TryCreateSymlink(symlinkDirectory, realDirectory.FullName);
        var suppliedPath = Path.Combine("link", appHostFile.Name);
        var displayPath = Path.GetFullPath(Path.Combine(workspace.WorkspaceRoot.FullName, suppliedPath));
        var canonicalPath = PathNormalizer.ResolveToFilesystemPath(displayPath);
        Assert.NotEqual(displayPath, canonicalPath);
 
        var monitor = new TestAuxiliaryBackchannelMonitor();
        if (hasMatchingConnection)
        {
            var connection = CreateConnection(appHostFile.FullName, processId: Environment.ProcessId);
            monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        }
 
        var tool = new SelectAppHostTool(
            monitor,
            TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot));
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(CreateArguments(suppliedPath)),
            TestContext.Current.CancellationToken);
 
        if (hasMatchingConnection)
        {
            Assert.Null(result.IsError);
            Assert.Equal($"Selected AppHost: {displayPath}", GetResultText(result));
            Assert.Equal(canonicalPath, monitor.SelectedAppHostPath);
        }
        else
        {
            Assert.True(result.IsError);
            AssertPathFreeSelectionError(
                result,
                "No running AppHost matched 'appHostPath'. No AppHosts are currently running.",
                displayPath,
                canonicalPath);
            Assert.Null(monitor.SelectedAppHostPath);
        }
    }
 
    [Fact]
    public async Task SelectAppHostTool_WithSymlinkedPath_MatchesPhysicalAppHostPath()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var realDirectory = workspace.WorkspaceRoot.CreateSubdirectory("real");
        var realAppHostPath = Path.Combine(realDirectory.FullName, "Symlinked.AppHost.csproj");
        File.WriteAllText(realAppHostPath, "<Project />");
        var symlinkDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "link");
 
        try
        {
            Directory.CreateSymbolicLink(symlinkDirectory, realDirectory.FullName);
        }
        catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or PlatformNotSupportedException)
        {
            Assert.Skip($"Cannot create a directory symlink in this environment: {ex.Message}");
        }
 
        var symlinkedAppHostPath = Path.Combine(symlinkDirectory, "Symlinked.AppHost.csproj");
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var connection = CreateConnection(realAppHostPath, processId: 2);
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
        var tool = new SelectAppHostTool(
            monitor,
            TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot));
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(CreateArguments(symlinkedAppHostPath)),
            TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
        Assert.Equal(PathNormalizer.ResolveToFilesystemPath(realAppHostPath), monitor.SelectedAppHostPath);
    }
 
    [Fact]
    public async Task SelectAppHostTool_WithCaseVariant_FollowsCurrentVolumeBehavior()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot);
        var directory = workspace.WorkspaceRoot.CreateSubdirectory("CaseSensitiveAppHost");
        var actualAppHostPath = Path.Combine(directory.FullName, "CaseSensitive.AppHost.csproj");
        File.WriteAllText(actualAppHostPath, "<Project />");
        var selectedAppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "casesensitiveapphost", "casesensitive.apphost.csproj");
        var volumeResolvesCaseVariant = File.Exists(selectedAppHostPath);
        var connection = CreateConnection(actualAppHostPath, processId: 1);
 
        monitor.AddConnection(connection.Hash, connection.SocketPath, connection);
 
        var tool = new SelectAppHostTool(monitor, executionContext);
        var result = await tool.CallToolAsync(CallToolContextTestHelper.Create(CreateArguments(selectedAppHostPath)), CancellationToken.None).DefaultTimeout();
 
        if (volumeResolvesCaseVariant)
        {
            Assert.True(result.IsError is null or false, $"Tool returned error: {GetResultText(result)}");
            Assert.Equal(PathNormalizer.ResolveToFilesystemPath(actualAppHostPath), monitor.SelectedAppHostPath);
        }
        else
        {
            Assert.True(result.IsError is true, "Case-variant AppHost selection should fail on a case-sensitive volume.");
            Assert.Null(monitor.SelectedAppHostPath);
 
            AssertPathFreeSelectionError(
                result,
                "No running AppHost matched 'appHostPath'. Other AppHosts are currently running.",
                selectedAppHostPath,
                actualAppHostPath);
        }
 
        var selectedConnection = await AppHostConnectionHelper.GetSelectedConnectionAsync(monitor, NullLogger.Instance, TestContext.Current.CancellationToken);
 
        Assert.Same(connection, selectedConnection);
    }
 
    [Fact]
    public async Task SelectAppHostTool_MissingPathDoesNotExposeRequestedOrAvailableAbsolutePaths()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var firstDirectory = workspace.WorkspaceRoot.CreateSubdirectory("FirstAppHost");
        var secondDirectory = workspace.WorkspaceRoot.CreateSubdirectory("SecondAppHost");
        var firstAppHostPath = Path.Combine(firstDirectory.FullName, "First.AppHost.csproj");
        var secondAppHostPath = Path.Combine(secondDirectory.FullName, "Second.AppHost.csproj");
        var requestedAppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "Missing", "Missing.AppHost.csproj");
        File.WriteAllText(firstAppHostPath, "<Project />");
        File.WriteAllText(secondAppHostPath, "<Project />");
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var firstConnection = CreateConnection(firstAppHostPath, processId: 1);
        var secondConnection = CreateConnection(secondAppHostPath, processId: 2);
        monitor.AddConnection(firstConnection.Hash, firstConnection.SocketPath, firstConnection);
        monitor.AddConnection(secondConnection.Hash, secondConnection.SocketPath, secondConnection);
        var tool = new SelectAppHostTool(
            monitor,
            TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot));
 
        var result = await tool.CallToolAsync(
            CallToolContextTestHelper.Create(CreateArguments(requestedAppHostPath)),
            TestContext.Current.CancellationToken).DefaultTimeout();
 
        Assert.True(result.IsError is true);
        Assert.Null(monitor.SelectedAppHostPath);
        AssertPathFreeSelectionError(
            result,
            "No running AppHost matched 'appHostPath'. Other AppHosts are currently running.",
            requestedAppHostPath,
            firstAppHostPath,
            secondAppHostPath);
    }
 
    private static Dictionary<string, JsonElement> CreateArguments(string appHostPath)
    {
        return new Dictionary<string, JsonElement>(StringComparer.Ordinal)
        {
            ["appHostPath"] = JsonDocument.Parse(JsonSerializer.Serialize(appHostPath)).RootElement
        };
    }
 
    private static TestAppHostAuxiliaryBackchannel CreateConnection(string appHostPath, int processId)
        => new()
        {
            Hash = $"hash-{processId}",
            SocketPath = $"socket-{processId}",
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = appHostPath,
                ProcessId = processId,
                CliProcessId = null
            },
            IsInScope = true
        };
 
    private static string GetResultText(CallToolResult result)
        => result.Content?.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? string.Empty;
 
    private static void AssertPathFreeSelectionError(
        CallToolResult result,
        string expectedMessage,
        params string[] absolutePaths)
    {
        var text = GetResultText(result);
        Assert.Equal(expectedMessage, text);
        Assert.All(absolutePaths, path => Assert.DoesNotContain(path, text, StringComparison.Ordinal));
    }
}